// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.actions.search; import static org.openstreetmap.josm.tools.I18n.marktr; import static org.openstreetmap.josm.tools.I18n.tr; import java.io.PushbackReader; import java.io.StringReader; import java.text.Normalizer; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.actions.search.PushbackTokenizer.Range; import org.openstreetmap.josm.actions.search.PushbackTokenizer.Token; import org.openstreetmap.josm.data.Bounds; import org.openstreetmap.josm.data.coor.LatLon; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.OsmPrimitiveType; import org.openstreetmap.josm.data.osm.OsmUtils; import org.openstreetmap.josm.data.osm.Relation; import org.openstreetmap.josm.data.osm.RelationMember; import org.openstreetmap.josm.data.osm.Tagged; import org.openstreetmap.josm.data.osm.Way; import org.openstreetmap.josm.gui.mappaint.Environment; import org.openstreetmap.josm.gui.mappaint.mapcss.Selector; import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser; import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException; import org.openstreetmap.josm.tools.AlphanumComparator; import org.openstreetmap.josm.tools.Geometry; import org.openstreetmap.josm.tools.UncheckedParseException; import org.openstreetmap.josm.tools.Utils; import org.openstreetmap.josm.tools.date.DateUtils; /** Implements a google-like search. <br> Grammar: <pre> expression = fact | expression fact expression fact fact = ( expression ) -fact term? term=term term:term term </pre> @author Imi */ public class SearchCompiler { private final boolean caseSensitive; private final boolean regexSearch; private static String rxErrorMsg = marktr("The regex \"{0}\" had a parse error at offset {1}, full error:\n\n{2}"); private static String rxErrorMsgNoPos = marktr("The regex \"{0}\" had a parse error, full error:\n\n{1}"); private final PushbackTokenizer tokenizer; private static Map<String, SimpleMatchFactory> simpleMatchFactoryMap = new HashMap<>(); private static Map<String, UnaryMatchFactory> unaryMatchFactoryMap = new HashMap<>(); private static Map<String, BinaryMatchFactory> binaryMatchFactoryMap = new HashMap<>(); public SearchCompiler(boolean caseSensitive, boolean regexSearch, PushbackTokenizer tokenizer) { this.caseSensitive = caseSensitive; this.regexSearch = regexSearch; this.tokenizer = tokenizer; // register core match factories at first instance, so plugins should never be able to generate a NPE if (simpleMatchFactoryMap.isEmpty()) { addMatchFactory(new CoreSimpleMatchFactory()); } if (unaryMatchFactoryMap.isEmpty()) { addMatchFactory(new CoreUnaryMatchFactory()); } } /** * Add (register) MatchFactory with SearchCompiler * @param factory match factory */ public static void addMatchFactory(MatchFactory factory) { for (String keyword : factory.getKeywords()) { final MatchFactory existing; if (factory instanceof SimpleMatchFactory) { existing = simpleMatchFactoryMap.put(keyword, (SimpleMatchFactory) factory); } else if (factory instanceof UnaryMatchFactory) { existing = unaryMatchFactoryMap.put(keyword, (UnaryMatchFactory) factory); } else if (factory instanceof BinaryMatchFactory) { existing = binaryMatchFactoryMap.put(keyword, (BinaryMatchFactory) factory); } else throw new AssertionError("Unknown match factory"); if (existing != null) { Main.warn("SearchCompiler: for key ''{0}'', overriding match factory ''{1}'' with ''{2}''", keyword, existing, factory); } } } public class CoreSimpleMatchFactory implements SimpleMatchFactory { private final Collection<String> keywords = Arrays.asList("id", "version", "type", "user", "role", "changeset", "nodes", "ways", "tags", "areasize", "waylength", "modified", "deleted", "selected", "incomplete", "untagged", "closed", "new", "indownloadedarea", "allindownloadedarea", "inview", "allinview", "timestamp", "nth", "nth%", "hasRole"); @Override public Match get(String keyword, PushbackTokenizer tokenizer) throws ParseError { switch(keyword) { case "modified": return new Modified(); case "deleted": return new Deleted(); case "selected": return new Selected(); case "incomplete": return new Incomplete(); case "untagged": return new Untagged(); case "closed": return new Closed(); case "new": return new New(); case "indownloadedarea": return new InDataSourceArea(false); case "allindownloadedarea": return new InDataSourceArea(true); case "inview": return new InView(false); case "allinview": return new InView(true); default: if (tokenizer != null) { switch (keyword) { case "id": return new Id(tokenizer); case "version": return new Version(tokenizer); case "type": return new ExactType(tokenizer.readTextOrNumber()); case "user": return new UserMatch(tokenizer.readTextOrNumber()); case "role": return new RoleMatch(tokenizer.readTextOrNumber()); case "changeset": return new ChangesetId(tokenizer); case "nodes": return new NodeCountRange(tokenizer); case "ways": return new WayCountRange(tokenizer); case "tags": return new TagCountRange(tokenizer); case "areasize": return new AreaSize(tokenizer); case "waylength": return new WayLength(tokenizer); case "nth": return new Nth(tokenizer, false); case "nth%": return new Nth(tokenizer, true); case "hasRole": return new HasRole(tokenizer); case "timestamp": // add leading/trailing space in order to get expected split (e.g. "a--" => {"a", ""}) String rangeS = ' ' + tokenizer.readTextOrNumber() + ' '; String[] rangeA = rangeS.split("/"); if (rangeA.length == 1) { return new KeyValue(keyword, rangeS.trim(), regexSearch, caseSensitive); } else if (rangeA.length == 2) { String rangeA1 = rangeA[0].trim(); String rangeA2 = rangeA[1].trim(); final long minDate; final long maxDate; try { // if min timestap is empty: use lowest possible date minDate = DateUtils.fromString(rangeA1.isEmpty() ? "1980" : rangeA1).getTime(); } catch (UncheckedParseException ex) { throw new ParseError(tr("Cannot parse timestamp ''{0}''", rangeA1), ex); } try { // if max timestamp is empty: use "now" maxDate = rangeA2.isEmpty() ? System.currentTimeMillis() : DateUtils.fromString(rangeA2).getTime(); } catch (UncheckedParseException ex) { throw new ParseError(tr("Cannot parse timestamp ''{0}''", rangeA2), ex); } return new TimestampRange(minDate, maxDate); } else { throw new ParseError("<html>" + tr("Expecting {0} after {1}", "<i>min</i>/<i>max</i>", "<i>timestamp</i>")); } } } else { throw new ParseError("<html>" + tr("Expecting {0} after {1}", "<code>:</code>", "<i>" + keyword + "</i>")); } } throw new IllegalStateException("Not expecting keyword " + keyword); } @Override public Collection<String> getKeywords() { return keywords; } } public static class CoreUnaryMatchFactory implements UnaryMatchFactory { private static Collection<String> keywords = Arrays.asList("parent", "child"); @Override public UnaryMatch get(String keyword, Match matchOperand, PushbackTokenizer tokenizer) { if ("parent".equals(keyword)) return new Parent(matchOperand); else if ("child".equals(keyword)) return new Child(matchOperand); return null; } @Override public Collection<String> getKeywords() { return keywords; } } /** * Classes implementing this interface can provide Match operators. * @since 10600 (functional interface) */ @FunctionalInterface private interface MatchFactory { Collection<String> getKeywords(); } public interface SimpleMatchFactory extends MatchFactory { Match get(String keyword, PushbackTokenizer tokenizer) throws ParseError; } public interface UnaryMatchFactory extends MatchFactory { UnaryMatch get(String keyword, Match matchOperand, PushbackTokenizer tokenizer) throws ParseError; } public interface BinaryMatchFactory extends MatchFactory { AbstractBinaryMatch get(String keyword, Match lhs, Match rhs, PushbackTokenizer tokenizer) throws ParseError; } /** * Base class for all search criteria. If the criterion only depends on an object's tags, * inherit from {@link org.openstreetmap.josm.actions.search.SearchCompiler.TaggedMatch}. */ public abstract static class Match implements Predicate<OsmPrimitive> { /** * Tests whether the primitive matches this criterion. * @param osm the primitive to test * @return true if the primitive matches this criterion */ public abstract boolean match(OsmPrimitive osm); /** * Tests whether the tagged object matches this criterion. * @param tagged the tagged object to test * @return true if the tagged object matches this criterion */ public boolean match(Tagged tagged) { return false; } @Override public final boolean test(OsmPrimitive object) { return match(object); } } public abstract static class TaggedMatch extends Match { @Override public abstract boolean match(Tagged tags); @Override public final boolean match(OsmPrimitive osm) { return match((Tagged) osm); } } /** * A unary search operator which may take data parameters. */ public abstract static class UnaryMatch extends Match { protected final Match match; public UnaryMatch(Match match) { if (match == null) { // "operator" (null) should mean the same as "operator()" // (Always). I.e. match everything this.match = Always.INSTANCE; } else { this.match = match; } } public Match getOperand() { return match; } } /** * A binary search operator which may take data parameters. */ public abstract static class AbstractBinaryMatch extends Match { protected final Match lhs; protected final Match rhs; /** * Constructs a new {@code BinaryMatch}. * @param lhs Left hand side * @param rhs Right hand side */ public AbstractBinaryMatch(Match lhs, Match rhs) { this.lhs = lhs; this.rhs = rhs; } /** * Returns left hand side. * @return left hand side */ public final Match getLhs() { return lhs; } /** * Returns right hand side. * @return right hand side */ public final Match getRhs() { return rhs; } protected static String parenthesis(Match m) { return '(' + m.toString() + ')'; } } /** * Matches every OsmPrimitive. */ public static class Always extends TaggedMatch { /** The unique instance/ */ public static final Always INSTANCE = new Always(); @Override public boolean match(Tagged osm) { return true; } } /** * Never matches any OsmPrimitive. */ public static class Never extends TaggedMatch { /** The unique instance/ */ public static final Never INSTANCE = new Never(); @Override public boolean match(Tagged osm) { return false; } } /** * Inverts the match. */ public static class Not extends UnaryMatch { public Not(Match match) { super(match); } @Override public boolean match(OsmPrimitive osm) { return !match.match(osm); } @Override public boolean match(Tagged osm) { return !match.match(osm); } @Override public String toString() { return '!' + match.toString(); } public Match getMatch() { return match; } } /** * Matches if the value of the corresponding key is ''yes'', ''true'', ''1'' or ''on''. */ private static class BooleanMatch extends TaggedMatch { private final String key; private final boolean defaultValue; BooleanMatch(String key, boolean defaultValue) { this.key = key; this.defaultValue = defaultValue; } @Override public boolean match(Tagged osm) { return Optional.ofNullable(OsmUtils.getOsmBoolean(osm.get(key))).orElse(defaultValue); } @Override public String toString() { return key + '?'; } } /** * Matches if both left and right expressions match. */ public static class And extends AbstractBinaryMatch { /** * Constructs a new {@code And} match. * @param lhs left hand side * @param rhs right hand side */ public And(Match lhs, Match rhs) { super(lhs, rhs); } @Override public boolean match(OsmPrimitive osm) { return lhs.match(osm) && rhs.match(osm); } @Override public boolean match(Tagged osm) { return lhs.match(osm) && rhs.match(osm); } @Override public String toString() { return (lhs instanceof AbstractBinaryMatch && !(lhs instanceof And) ? parenthesis(lhs) : lhs) + " && " + (rhs instanceof AbstractBinaryMatch && !(rhs instanceof And) ? parenthesis(rhs) : rhs); } } /** * Matches if the left OR the right expression match. */ public static class Or extends AbstractBinaryMatch { /** * Constructs a new {@code Or} match. * @param lhs left hand side * @param rhs right hand side */ public Or(Match lhs, Match rhs) { super(lhs, rhs); } @Override public boolean match(OsmPrimitive osm) { return lhs.match(osm) || rhs.match(osm); } @Override public boolean match(Tagged osm) { return lhs.match(osm) || rhs.match(osm); } @Override public String toString() { return (lhs instanceof AbstractBinaryMatch && !(lhs instanceof Or) ? parenthesis(lhs) : lhs) + " || " + (rhs instanceof AbstractBinaryMatch && !(rhs instanceof Or) ? parenthesis(rhs) : rhs); } } /** * Matches if the left OR the right expression match, but not both. */ public static class Xor extends AbstractBinaryMatch { /** * Constructs a new {@code Xor} match. * @param lhs left hand side * @param rhs right hand side */ public Xor(Match lhs, Match rhs) { super(lhs, rhs); } @Override public boolean match(OsmPrimitive osm) { return lhs.match(osm) ^ rhs.match(osm); } @Override public boolean match(Tagged osm) { return lhs.match(osm) ^ rhs.match(osm); } @Override public String toString() { return (lhs instanceof AbstractBinaryMatch && !(lhs instanceof Xor) ? parenthesis(lhs) : lhs) + " ^ " + (rhs instanceof AbstractBinaryMatch && !(rhs instanceof Xor) ? parenthesis(rhs) : rhs); } } /** * Matches objects with ID in the given range. */ private static class Id extends RangeMatch { Id(Range range) { super(range); } Id(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of primitive ids expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { return osm.isNew() ? 0 : osm.getUniqueId(); } @Override protected String getString() { return "id"; } } /** * Matches objects with a changeset ID in the given range. */ private static class ChangesetId extends RangeMatch { ChangesetId(Range range) { super(range); } ChangesetId(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of changeset ids expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { return (long) osm.getChangesetId(); } @Override protected String getString() { return "changeset"; } } /** * Matches objects with a version number in the given range. */ private static class Version extends RangeMatch { Version(Range range) { super(range); } Version(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of versions expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { return (long) osm.getVersion(); } @Override protected String getString() { return "version"; } } /** * Matches objects with the given key-value pair. */ private static class KeyValue extends TaggedMatch { private final String key; private final Pattern keyPattern; private final String value; private final Pattern valuePattern; private final boolean caseSensitive; KeyValue(String key, String value, boolean regexSearch, boolean caseSensitive) throws ParseError { this.caseSensitive = caseSensitive; if (regexSearch) { int searchFlags = regexFlags(caseSensitive); try { this.keyPattern = Pattern.compile(key, searchFlags); } catch (PatternSyntaxException e) { throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()), e); } catch (IllegalArgumentException e) { throw new ParseError(tr(rxErrorMsgNoPos, key, e.getMessage()), e); } try { this.valuePattern = Pattern.compile(value, searchFlags); } catch (PatternSyntaxException e) { throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()), e); } catch (IllegalArgumentException | StringIndexOutOfBoundsException e) { throw new ParseError(tr(rxErrorMsgNoPos, value, e.getMessage()), e); } this.key = key; this.value = value; } else { this.key = key; this.value = value; this.keyPattern = null; this.valuePattern = null; } } @Override public boolean match(Tagged osm) { if (keyPattern != null) { if (!osm.hasKeys()) return false; /* The string search will just get a key like * 'highway' and look that up as osm.get(key). But * since we're doing a regex match we'll have to loop * over all the keys to see if they match our regex, * and only then try to match against the value */ for (String k: osm.keySet()) { String v = osm.get(k); Matcher matcherKey = keyPattern.matcher(k); boolean matchedKey = matcherKey.find(); if (matchedKey) { Matcher matcherValue = valuePattern.matcher(v); boolean matchedValue = matcherValue.find(); if (matchedValue) return true; } } } else { String mv; if ("timestamp".equals(key) && osm instanceof OsmPrimitive) { mv = DateUtils.fromTimestamp(((OsmPrimitive) osm).getRawTimestamp()); } else { mv = osm.get(key); if (!caseSensitive && mv == null) { for (String k: osm.keySet()) { if (key.equalsIgnoreCase(k)) { mv = osm.get(k); break; } } } } if (mv == null) return false; String v1 = caseSensitive ? mv : mv.toLowerCase(Locale.ENGLISH); String v2 = caseSensitive ? value : value.toLowerCase(Locale.ENGLISH); v1 = Normalizer.normalize(v1, Normalizer.Form.NFC); v2 = Normalizer.normalize(v2, Normalizer.Form.NFC); return v1.indexOf(v2) != -1; } return false; } @Override public String toString() { return key + '=' + value; } } public static class ValueComparison extends TaggedMatch { private final String key; private final String referenceValue; private final Double referenceNumber; private final int compareMode; private static final Pattern ISO8601 = Pattern.compile("\\d+-\\d+-\\d+"); public ValueComparison(String key, String referenceValue, int compareMode) { this.key = key; this.referenceValue = referenceValue; Double v = null; try { if (referenceValue != null) { v = Double.valueOf(referenceValue); } } catch (NumberFormatException ignore) { Main.trace(ignore); } this.referenceNumber = v; this.compareMode = compareMode; } @Override public boolean match(Tagged osm) { final String currentValue = osm.get(key); final int compareResult; if (currentValue == null) { return false; } else if (ISO8601.matcher(currentValue).matches() || ISO8601.matcher(referenceValue).matches()) { compareResult = currentValue.compareTo(referenceValue); } else if (referenceNumber != null) { try { compareResult = Double.compare(Double.parseDouble(currentValue), referenceNumber); } catch (NumberFormatException ignore) { return false; } } else { compareResult = AlphanumComparator.getInstance().compare(currentValue, referenceValue); } return compareMode < 0 ? compareResult < 0 : compareMode > 0 ? compareResult > 0 : compareResult == 0; } @Override public String toString() { return key + (compareMode == -1 ? "<" : compareMode == +1 ? ">" : "") + referenceValue; } } /** * Matches objects with the exact given key-value pair. */ public static class ExactKeyValue extends TaggedMatch { private enum Mode { ANY, ANY_KEY, ANY_VALUE, EXACT, NONE, MISSING_KEY, ANY_KEY_REGEXP, ANY_VALUE_REGEXP, EXACT_REGEXP, MISSING_KEY_REGEXP; } private final String key; private final String value; private final Pattern keyPattern; private final Pattern valuePattern; private final Mode mode; public ExactKeyValue(boolean regexp, String key, String value) throws ParseError { if ("".equals(key)) throw new ParseError(tr("Key cannot be empty when tag operator is used. Sample use: key=value")); this.key = key; this.value = value == null ? "" : value; if ("".equals(this.value) && "*".equals(key)) { mode = Mode.NONE; } else if ("".equals(this.value)) { if (regexp) { mode = Mode.MISSING_KEY_REGEXP; } else { mode = Mode.MISSING_KEY; } } else if ("*".equals(key) && "*".equals(this.value)) { mode = Mode.ANY; } else if ("*".equals(key)) { if (regexp) { mode = Mode.ANY_KEY_REGEXP; } else { mode = Mode.ANY_KEY; } } else if ("*".equals(this.value)) { if (regexp) { mode = Mode.ANY_VALUE_REGEXP; } else { mode = Mode.ANY_VALUE; } } else { if (regexp) { mode = Mode.EXACT_REGEXP; } else { mode = Mode.EXACT; } } if (regexp && !key.isEmpty() && !"*".equals(key)) { try { keyPattern = Pattern.compile(key, regexFlags(false)); } catch (PatternSyntaxException e) { throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()), e); } catch (IllegalArgumentException e) { throw new ParseError(tr(rxErrorMsgNoPos, key, e.getMessage()), e); } } else { keyPattern = null; } if (regexp && !this.value.isEmpty() && !"*".equals(this.value)) { try { valuePattern = Pattern.compile(this.value, regexFlags(false)); } catch (PatternSyntaxException e) { throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()), e); } catch (IllegalArgumentException e) { throw new ParseError(tr(rxErrorMsgNoPos, value, e.getMessage()), e); } } else { valuePattern = null; } } @Override public boolean match(Tagged osm) { if (!osm.hasKeys()) return mode == Mode.NONE; switch (mode) { case NONE: return false; case MISSING_KEY: return osm.get(key) == null; case ANY: return true; case ANY_VALUE: return osm.get(key) != null; case ANY_KEY: for (String v:osm.getKeys().values()) { if (v.equals(value)) return true; } return false; case EXACT: return value.equals(osm.get(key)); case ANY_KEY_REGEXP: for (String v:osm.getKeys().values()) { if (valuePattern.matcher(v).matches()) return true; } return false; case ANY_VALUE_REGEXP: case EXACT_REGEXP: for (String k : osm.keySet()) { if (keyPattern.matcher(k).matches() && (mode == Mode.ANY_VALUE_REGEXP || valuePattern.matcher(osm.get(k)).matches())) return true; } return false; case MISSING_KEY_REGEXP: for (String k:osm.keySet()) { if (keyPattern.matcher(k).matches()) return false; } return true; } throw new AssertionError("Missed state"); } @Override public String toString() { return key + '=' + value; } } /** * Match a string in any tags (key or value), with optional regex and case insensitivity. */ private static class Any extends TaggedMatch { private final String search; private final Pattern searchRegex; private final boolean caseSensitive; Any(String s, boolean regexSearch, boolean caseSensitive) throws ParseError { s = Normalizer.normalize(s, Normalizer.Form.NFC); this.caseSensitive = caseSensitive; if (regexSearch) { try { this.searchRegex = Pattern.compile(s, regexFlags(caseSensitive)); } catch (PatternSyntaxException e) { throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()), e); } catch (IllegalArgumentException | StringIndexOutOfBoundsException e) { // StringIndexOutOfBoundsException catched because of https://bugs.openjdk.java.net/browse/JI-9044959 // See #13870: To remove after we switch to a version of Java which resolves this bug throw new ParseError(tr(rxErrorMsgNoPos, s, e.getMessage()), e); } this.search = s; } else if (caseSensitive) { this.search = s; this.searchRegex = null; } else { this.search = s.toLowerCase(Locale.ENGLISH); this.searchRegex = null; } } @Override public boolean match(Tagged osm) { if (!osm.hasKeys()) return search.isEmpty(); for (String key: osm.keySet()) { String value = osm.get(key); if (searchRegex != null) { value = Normalizer.normalize(value, Normalizer.Form.NFC); Matcher keyMatcher = searchRegex.matcher(key); Matcher valMatcher = searchRegex.matcher(value); boolean keyMatchFound = keyMatcher.find(); boolean valMatchFound = valMatcher.find(); if (keyMatchFound || valMatchFound) return true; } else { if (!caseSensitive) { key = key.toLowerCase(Locale.ENGLISH); value = value.toLowerCase(Locale.ENGLISH); } value = Normalizer.normalize(value, Normalizer.Form.NFC); if (key.indexOf(search) != -1 || value.indexOf(search) != -1) return true; } } return false; } @Override public String toString() { return search; } } private static class ExactType extends Match { private final OsmPrimitiveType type; ExactType(String type) throws ParseError { this.type = OsmPrimitiveType.from(type); if (this.type == null) throw new ParseError(tr("Unknown primitive type: {0}. Allowed values are node, way or relation", type)); } @Override public boolean match(OsmPrimitive osm) { return type.equals(osm.getType()); } @Override public String toString() { return "type=" + type; } } /** * Matches objects last changed by the given username. */ private static class UserMatch extends Match { private String user; UserMatch(String user) { if ("anonymous".equals(user)) { this.user = null; } else { this.user = user; } } @Override public boolean match(OsmPrimitive osm) { if (osm.getUser() == null) return user == null; else return osm.getUser().hasName(user); } @Override public String toString() { return "user=" + (user == null ? "" : user); } } /** * Matches objects with the given relation role (i.e. "outer"). */ private static class RoleMatch extends Match { private String role; RoleMatch(String role) { if (role == null) { this.role = ""; } else { this.role = role; } } @Override public boolean match(OsmPrimitive osm) { for (OsmPrimitive ref: osm.getReferrers()) { if (ref instanceof Relation && !ref.isIncomplete() && !ref.isDeleted()) { for (RelationMember m : ((Relation) ref).getMembers()) { if (m.getMember() == osm) { String testRole = m.getRole(); if (role.equals(testRole == null ? "" : testRole)) return true; } } } } return false; } @Override public String toString() { return "role=" + role; } } /** * Matches the n-th object of a relation and/or the n-th node of a way. */ private static class Nth extends Match { private final int nth; private final boolean modulo; Nth(PushbackTokenizer tokenizer, boolean modulo) throws ParseError { this((int) tokenizer.readNumber(tr("Positive integer expected")), modulo); } private Nth(int nth, boolean modulo) { this.nth = nth; this.modulo = modulo; } @Override public boolean match(OsmPrimitive osm) { for (OsmPrimitive p : osm.getReferrers()) { final int idx; final int maxIndex; if (p instanceof Way) { Way w = (Way) p; idx = w.getNodes().indexOf(osm); maxIndex = w.getNodesCount(); } else if (p instanceof Relation) { Relation r = (Relation) p; idx = r.getMemberPrimitivesList().indexOf(osm); maxIndex = r.getMembersCount(); } else { continue; } if (nth < 0 && idx - maxIndex == nth) { return true; } else if (idx == nth || (modulo && idx % nth == 0)) return true; } return false; } @Override public String toString() { return "Nth{nth=" + nth + ", modulo=" + modulo + '}'; } } /** * Matches objects with properties in a certain range. */ private abstract static class RangeMatch extends Match { private final long min; private final long max; RangeMatch(long min, long max) { this.min = Math.min(min, max); this.max = Math.max(min, max); } RangeMatch(Range range) { this(range.getStart(), range.getEnd()); } protected abstract Long getNumber(OsmPrimitive osm); protected abstract String getString(); @Override public boolean match(OsmPrimitive osm) { Long num = getNumber(osm); if (num == null) return false; else return (num >= min) && (num <= max); } @Override public String toString() { return getString() + '=' + min + '-' + max; } } /** * Matches ways with a number of nodes in given range */ private static class NodeCountRange extends RangeMatch { NodeCountRange(Range range) { super(range); } NodeCountRange(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of numbers expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { if (osm instanceof Way) { return (long) ((Way) osm).getRealNodesCount(); } else if (osm instanceof Relation) { return (long) ((Relation) osm).getMemberPrimitives(Node.class).size(); } else { return null; } } @Override protected String getString() { return "nodes"; } } /** * Matches objects with the number of referring/contained ways in the given range */ private static class WayCountRange extends RangeMatch { WayCountRange(Range range) { super(range); } WayCountRange(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of numbers expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { if (osm instanceof Node) { return (long) Utils.filteredCollection(osm.getReferrers(), Way.class).size(); } else if (osm instanceof Relation) { return (long) ((Relation) osm).getMemberPrimitives(Way.class).size(); } else { return null; } } @Override protected String getString() { return "ways"; } } /** * Matches objects with a number of tags in given range */ private static class TagCountRange extends RangeMatch { TagCountRange(Range range) { super(range); } TagCountRange(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of numbers expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { return (long) osm.getKeys().size(); } @Override protected String getString() { return "tags"; } } /** * Matches objects with a timestamp in given range */ private static class TimestampRange extends RangeMatch { TimestampRange(long minCount, long maxCount) { super(minCount, maxCount); } @Override protected Long getNumber(OsmPrimitive osm) { return osm.getTimestamp().getTime(); } @Override protected String getString() { return "timestamp"; } } /** * Matches relations with a member of the given role */ private static class HasRole extends Match { private final String role; HasRole(PushbackTokenizer tokenizer) { role = tokenizer.readTextOrNumber(); } @Override public boolean match(OsmPrimitive osm) { return osm instanceof Relation && ((Relation) osm).getMemberRoles().contains(role); } } /** * Matches objects that are new (i.e. have not been uploaded to the server) */ private static class New extends Match { @Override public boolean match(OsmPrimitive osm) { return osm.isNew(); } @Override public String toString() { return "new"; } } /** * Matches all objects that have been modified, created, or undeleted */ private static class Modified extends Match { @Override public boolean match(OsmPrimitive osm) { return osm.isModified() || osm.isNewOrUndeleted(); } @Override public String toString() { return "modified"; } } /** * Matches all objects that have been deleted */ private static class Deleted extends Match { @Override public boolean match(OsmPrimitive osm) { return osm.isDeleted(); } @Override public String toString() { return "deleted"; } } /** * Matches all objects currently selected */ private static class Selected extends Match { @Override public boolean match(OsmPrimitive osm) { return osm.getDataSet().isSelected(osm); } @Override public String toString() { return "selected"; } } /** * Match objects that are incomplete, where only id and type are known. * Typically some members of a relation are incomplete until they are * fetched from the server. */ private static class Incomplete extends Match { @Override public boolean match(OsmPrimitive osm) { return osm.isIncomplete() || (osm instanceof Relation && ((Relation) osm).hasIncompleteMembers()); } @Override public String toString() { return "incomplete"; } } /** * Matches objects that don't have any interesting tags (i.e. only has source, * FIXME, etc.). The complete list of uninteresting tags can be found here: * org.openstreetmap.josm.data.osm.OsmPrimitive.getUninterestingKeys() */ private static class Untagged extends Match { @Override public boolean match(OsmPrimitive osm) { return !osm.isTagged() && !osm.isIncomplete(); } @Override public String toString() { return "untagged"; } } /** * Matches ways which are closed (i.e. first and last node are the same) */ private static class Closed extends Match { @Override public boolean match(OsmPrimitive osm) { return osm instanceof Way && ((Way) osm).isClosed(); } @Override public String toString() { return "closed"; } } /** * Matches objects if they are parents of the expression */ public static class Parent extends UnaryMatch { public Parent(Match m) { super(m); } @Override public boolean match(OsmPrimitive osm) { boolean isParent = false; if (osm instanceof Way) { for (Node n : ((Way) osm).getNodes()) { isParent |= match.match(n); } } else if (osm instanceof Relation) { for (RelationMember member : ((Relation) osm).getMembers()) { isParent |= match.match(member.getMember()); } } return isParent; } @Override public String toString() { return "parent(" + match + ')'; } } /** * Matches objects if they are children of the expression */ public static class Child extends UnaryMatch { public Child(Match m) { super(m); } @Override public boolean match(OsmPrimitive osm) { boolean isChild = false; for (OsmPrimitive p : osm.getReferrers()) { isChild |= match.match(p); } return isChild; } @Override public String toString() { return "child(" + match + ')'; } } /** * Matches if the size of the area is within the given range * * @author Ole Jørgen Brønner */ private static class AreaSize extends RangeMatch { AreaSize(Range range) { super(range); } AreaSize(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of numbers expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { final Double area = Geometry.computeArea(osm); return area == null ? null : area.longValue(); } @Override protected String getString() { return "areasize"; } } /** * Matches if the length of a way is within the given range */ private static class WayLength extends RangeMatch { WayLength(Range range) { super(range); } WayLength(PushbackTokenizer tokenizer) throws ParseError { this(tokenizer.readRange(tr("Range of numbers expected"))); } @Override protected Long getNumber(OsmPrimitive osm) { if (!(osm instanceof Way)) return null; Way way = (Way) osm; return (long) way.getLength(); } @Override protected String getString() { return "waylength"; } } /** * Matches objects within the given bounds. */ private abstract static class InArea extends Match { protected final boolean all; /** * @param all if true, all way nodes or relation members have to be within source area;if false, one suffices. */ InArea(boolean all) { this.all = all; } protected abstract Collection<Bounds> getBounds(OsmPrimitive primitive); @Override public boolean match(OsmPrimitive osm) { if (!osm.isUsable()) return false; else if (osm instanceof Node) { LatLon coordinate = ((Node) osm).getCoor(); Collection<Bounds> allBounds = getBounds(osm); return coordinate != null && allBounds != null && allBounds.stream().anyMatch(bounds -> bounds.contains(coordinate)); } else if (osm instanceof Way) { Collection<Node> nodes = ((Way) osm).getNodes(); return all ? nodes.stream().allMatch(this) : nodes.stream().anyMatch(this); } else if (osm instanceof Relation) { Collection<OsmPrimitive> primitives = ((Relation) osm).getMemberPrimitivesList(); return all ? primitives.stream().allMatch(this) : primitives.stream().anyMatch(this); } else return false; } } /** * Matches objects within source area ("downloaded area"). */ public static class InDataSourceArea extends InArea { /** * Constructs a new {@code InDataSourceArea}. * @param all if true, all way nodes or relation members have to be within source area; if false, one suffices. */ public InDataSourceArea(boolean all) { super(all); } @Override protected Collection<Bounds> getBounds(OsmPrimitive primitive) { return primitive.getDataSet() != null ? primitive.getDataSet().getDataSourceBounds() : null; } @Override public String toString() { return all ? "allindownloadedarea" : "indownloadedarea"; } } /** * Matches objects which are not outside the source area ("downloaded area"). * Unlike {@link InDataSourceArea} this matches also if no source area is set (e.g., for new layers). */ public static class NotOutsideDataSourceArea extends InDataSourceArea { /** * Constructs a new {@code NotOutsideDataSourceArea}. */ public NotOutsideDataSourceArea() { super(false); } @Override protected Collection<Bounds> getBounds(OsmPrimitive primitive) { final Collection<Bounds> bounds = super.getBounds(primitive); return bounds == null || bounds.isEmpty() ? Collections.singleton(Main.getProjection().getWorldBoundsLatLon()) : bounds; } @Override public String toString() { return "NotOutsideDataSourceArea"; } } /** * Matches objects within current map view. */ private static class InView extends InArea { InView(boolean all) { super(all); } @Override protected Collection<Bounds> getBounds(OsmPrimitive primitive) { if (!Main.isDisplayingMapView()) { return null; } return Collections.singleton(Main.map.mapView.getRealBounds()); } @Override public String toString() { return all ? "allinview" : "inview"; } } public static class ParseError extends Exception { public ParseError(String msg) { super(msg); } public ParseError(String msg, Throwable cause) { super(msg, cause); } public ParseError(Token expected, Token found) { this(tr("Unexpected token. Expected {0}, found {1}", expected, found)); } } /** * Compiles the search expression. * @param searchStr the search expression * @return a {@link Match} object for the expression * @throws ParseError if an error has been encountered while compiling * @see #compile(org.openstreetmap.josm.actions.search.SearchAction.SearchSetting) */ public static Match compile(String searchStr) throws ParseError { return new SearchCompiler(false, false, new PushbackTokenizer( new PushbackReader(new StringReader(searchStr)))) .parse(); } /** * Compiles the search expression. * @param setting the settings to use * @return a {@link Match} object for the expression * @throws ParseError if an error has been encountered while compiling * @see #compile(String) */ public static Match compile(SearchAction.SearchSetting setting) throws ParseError { if (setting.mapCSSSearch) { return compileMapCSS(setting.text); } return new SearchCompiler(setting.caseSensitive, setting.regexSearch, new PushbackTokenizer( new PushbackReader(new StringReader(setting.text)))) .parse(); } static Match compileMapCSS(String mapCSS) throws ParseError { try { final List<Selector> selectors = new MapCSSParser(new StringReader(mapCSS)).selectors(); return new Match() { @Override public boolean match(OsmPrimitive osm) { for (Selector selector : selectors) { if (selector.matches(new Environment(osm))) { return true; } } return false; } }; } catch (ParseException e) { throw new ParseError(tr("Failed to parse MapCSS selector"), e); } } /** * Parse search string. * * @return match determined by search string * @throws org.openstreetmap.josm.actions.search.SearchCompiler.ParseError if search expression cannot be parsed */ public Match parse() throws ParseError { Match m = Optional.ofNullable(parseExpression()).orElse(Always.INSTANCE); if (!tokenizer.readIfEqual(Token.EOF)) throw new ParseError(tr("Unexpected token: {0}", tokenizer.nextToken())); Main.debug("Parsed search expression is {0}", m); return m; } /** * Parse expression. * * @return match determined by parsing expression * @throws ParseError if search expression cannot be parsed */ private Match parseExpression() throws ParseError { // Step 1: parse the whole expression and build a list of factors and logical tokens List<Object> list = parseExpressionStep1(); // Step 2: iterate the list in reverse order to build the logical expression // This iterative approach avoids StackOverflowError for long expressions (see #14217) return parseExpressionStep2(list); } private List<Object> parseExpressionStep1() throws ParseError { Match factor; String token = null; String errorMessage = null; List<Object> list = new ArrayList<>(); do { factor = parseFactor(); if (factor != null) { if (token != null) { list.add(token); } list.add(factor); if (tokenizer.readIfEqual(Token.OR)) { token = "OR"; errorMessage = tr("Missing parameter for OR"); } else if (tokenizer.readIfEqual(Token.XOR)) { token = "XOR"; errorMessage = tr("Missing parameter for XOR"); } else { token = "AND"; errorMessage = null; } } else if (errorMessage != null) { throw new ParseError(errorMessage); } } while (factor != null); return list; } private static Match parseExpressionStep2(List<Object> list) { Match result = null; for (int i = list.size() - 1; i >= 0; i--) { Object o = list.get(i); if (o instanceof Match && result == null) { result = (Match) o; } else if (o instanceof String && i > 0) { Match factor = (Match) list.get(i-1); switch ((String) o) { case "OR": result = new Or(factor, result); break; case "XOR": result = new Xor(factor, result); break; case "AND": result = new And(factor, result); break; default: throw new IllegalStateException(tr("Unexpected token: {0}", o)); } i--; } else { throw new IllegalStateException("i=" + i + "; o=" + o); } } return result; } /** * Parse next factor (a search operator or search term). * * @return match determined by parsing factor string * @throws ParseError if search expression cannot be parsed */ private Match parseFactor() throws ParseError { if (tokenizer.readIfEqual(Token.LEFT_PARENT)) { Match expression = parseExpression(); if (!tokenizer.readIfEqual(Token.RIGHT_PARENT)) throw new ParseError(Token.RIGHT_PARENT, tokenizer.nextToken()); return expression; } else if (tokenizer.readIfEqual(Token.NOT)) { return new Not(parseFactor(tr("Missing operator for NOT"))); } else if (tokenizer.readIfEqual(Token.KEY)) { // factor consists of key:value or key=value String key = tokenizer.getText(); if (tokenizer.readIfEqual(Token.EQUALS)) { return new ExactKeyValue(regexSearch, key, tokenizer.readTextOrNumber()); } else if (tokenizer.readIfEqual(Token.LESS_THAN)) { return new ValueComparison(key, tokenizer.readTextOrNumber(), -1); } else if (tokenizer.readIfEqual(Token.GREATER_THAN)) { return new ValueComparison(key, tokenizer.readTextOrNumber(), +1); } else if (tokenizer.readIfEqual(Token.COLON)) { // see if we have a Match that takes a data parameter SimpleMatchFactory factory = simpleMatchFactoryMap.get(key); if (factory != null) return factory.get(key, tokenizer); UnaryMatchFactory unaryFactory = unaryMatchFactoryMap.get(key); if (unaryFactory != null) return unaryFactory.get(key, parseFactor(), tokenizer); // key:value form where value is a string (may be OSM key search) final String value = tokenizer.readTextOrNumber(); return new KeyValue(key, value != null ? value : "", regexSearch, caseSensitive); } else if (tokenizer.readIfEqual(Token.QUESTION_MARK)) return new BooleanMatch(key, false); else { SimpleMatchFactory factory = simpleMatchFactoryMap.get(key); if (factory != null) return factory.get(key, null); UnaryMatchFactory unaryFactory = unaryMatchFactoryMap.get(key); if (unaryFactory != null) return unaryFactory.get(key, parseFactor(), null); // match string in any key or value return new Any(key, regexSearch, caseSensitive); } } else return null; } private Match parseFactor(String errorMessage) throws ParseError { return Optional.ofNullable(parseFactor()).orElseThrow(() -> new ParseError(errorMessage)); } private static int regexFlags(boolean caseSensitive) { int searchFlags = 0; // Enables canonical Unicode equivalence so that e.g. the two // forms of "\u00e9gal" and "e\u0301gal" will match. // // It makes sense to match no matter how the character // happened to be constructed. searchFlags |= Pattern.CANON_EQ; // Make "." match any character including newline (/s in Perl) searchFlags |= Pattern.DOTALL; // CASE_INSENSITIVE by itself only matches US-ASCII case // insensitively, but the OSM data is in Unicode. With // UNICODE_CASE casefolding is made Unicode-aware. if (!caseSensitive) { searchFlags |= (Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); } return searchFlags; } static String escapeStringForSearch(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } /** * Builds a search string for the given tag. If value is empty, the existence of the key is checked. * * @param key the tag key * @param value the tag value * @return a search string for the given tag */ public static String buildSearchStringForTag(String key, String value) { final String forKey = '"' + escapeStringForSearch(key) + '"' + '='; if (value == null || value.isEmpty()) { return forKey + '*'; } else { return forKey + '"' + escapeStringForSearch(value) + '"'; } } }