1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
use crate::{Error, Expr, Geometry};
use pest::{iterators::Pairs, pratt_parser::PrattParser, Parser};
use std::borrow::Cow;
/// Parses a cql2-text string into a CQL2 expression.
///
/// # Examples
///
/// ```
/// let s = "landsat:scene_id = 'LC82030282019133LGN00'";
/// let expr = cql2::parse_text(s);
/// ```
pub fn parse_text(s: &str) -> Result<Expr, Error> {
// `ExprRoot` is anchored between `SOI` and `EOI`, so input the grammar cannot consume in full
// is a parse error rather than a silently truncated expression.
let mut pairs = CQL2Parser::parse(Rule::ExprRoot, s).map_err(Box::new)?;
let expr = pairs
.next()
.ok_or_else(|| Error::InvalidCql2Text(s.to_string()))?;
parse_expr(expr.into_inner()).map(crate::expr::normalize)
}
#[derive(pest_derive::Parser)]
#[grammar = "cql2.pest"]
struct CQL2Parser;
lazy_static::lazy_static! {
static ref PRATT_PARSER: PrattParser<Rule> = {
use pest::pratt_parser::{Assoc::*, Op};
use Rule::*;
PrattParser::new()
// Ordered loosest to tightest, mirroring `crate::precedence`.
.op(Op::infix(Or, Left))
.op(Op::infix(And, Left))
.op(Op::prefix(UnaryNot))
.op(Op::infix(Eq, Right))
.op(
Op::infix(NotEq, Right) |
Op::infix(Gt, Right) |
Op::infix(GtEq, Right) |
Op::infix(Lt, Right) |
Op::infix(LtEq, Right)
)
.op(Op::infix(Like, Right))
.op(Op::infix(In, Left))
// `BETWEEN` is a postfix predicate on its left operand, binding looser than arithmetic
// (`a + b BETWEEN 1 AND 2` brackets the sum) and tighter than the boolean connectives.
.op(Op::postfix(IsNullPostfix) | Op::postfix(BetweenPostfix))
.op(
Op::infix(Add, Left) |
Op::infix(Subtract, Left)
)
.op(
Op::infix(Multiply, Left) |
Op::infix(Divide, Left) |
Op::infix(Modulo, Left)
)
.op(Op::infix(Power, Left))
.op(Op::prefix(Negative))
};
}
/// Unwraps a quoted token, undoing the doubling that escapes the quote character inside it.
pub(crate) fn strip_quotes(s: &str) -> Cow<'_, str> {
for quote in ['"', '\''] {
if let Some(inner) = s
.strip_prefix(quote)
.and_then(|rest| rest.strip_suffix(quote))
{
let doubled = [quote, quote].iter().collect::<String>();
return if inner.contains(&doubled) {
Cow::Owned(inner.replace(&doubled, "e.to_string()))
} else {
Cow::Borrowed(inner)
};
}
}
Cow::Borrowed(s)
}
/// Replaces every internal run of whitespace with a single space, and removes leading and trailing
/// runs entirely.
fn collapse_whitespace(wkt: &str) -> String {
wkt.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Restores a one-element array operand that the grammar read as a parenthesized scalar.
///
/// `(1)` is ambiguous in cql2-text: `AtomicExpr` tries grouping before an array literal, so a
/// single-element list arrives as the value itself. Only the operators whose operand is a list are
/// affected, and only a bare scalar is rewrapped — a property or an expression may legitimately
/// evaluate to an array.
fn restore_single_element_array(op: &str, args: &mut [Box<Expr>]) {
if !crate::expr::ARRAYOPS
.iter()
.any(|name| name.eq_ignore_ascii_case(op))
{
return;
}
for arg in args.iter_mut() {
if matches!(
arg.as_ref(),
Expr::Float(_) | Expr::Literal(_) | Expr::Bool(_)
) {
**arg = Expr::Array(vec![arg.clone()]);
}
}
}
fn parse_expr(expression_pairs: Pairs<'_, Rule>) -> Result<Expr, Error> {
PRATT_PARSER
.map_primary(|primary| match primary.as_rule() {
Rule::Expr | Rule::ExpressionInParentheses | Rule::BetweenOperand => {
parse_expr(primary.into_inner())
}
Rule::DECIMAL | Rule::Double => Ok(Expr::Float(primary.as_str().parse::<f64>()?)),
Rule::SingleQuotedString => {
Ok(Expr::Literal(strip_quotes(primary.as_str()).to_string()))
}
Rule::True | Rule::False => Ok(Expr::Bool(primary.as_rule() == Rule::True)),
Rule::Identifier => Ok(Expr::Property {
property: strip_quotes(primary.as_str()).to_string(),
}),
Rule::GEOMETRY => {
// CQL2 allows coordinates past the second without the `Z` or `ZM` marker OGC WKT
// requires, so the marker is spliced in before the geometry is handed on. Which one
// follows from the widest coordinate the grammar matched.
let start = primary.as_span().start();
let s = primary.as_str().to_string();
let pairs = primary.into_inner();
// The grammar matches a nested collection so that it is read as the geometry it
// looks like rather than as a function call, and it is refused here: CQL2 gives a
// collection's members as the six non-collection types, so there is no cql2-json
// encoding for one. More than one `GEOMETRYCOLLECTION` in the token is nesting,
// since a collection is the only rule a second one can appear inside.
if pairs
.clone()
.flatten()
.filter(|pair| pair.as_rule() == Rule::GEOMETRY_COLLECTION)
.count()
> 1
{
return Err(Error::NestedGeometryCollection);
}
let marker = if pairs.find_first_tagged("four_d").is_some() {
" ZM"
} else if pairs.find_first_tagged("three_d").is_some() {
" Z"
} else {
return Ok(Expr::Geometry(Geometry::Wkt(collapse_whitespace(&s))));
};
// Every unmarked geometry in the token needs it. A collection carries the marker and
// so does each of its members, and no WKT reader accepts a mix.
let mut slots: Vec<(usize, usize)> = pairs
.flatten()
.filter(|pair| matches!(pair.as_rule(), Rule::ZM))
.filter(|pair| pair.as_str().chars().all(char::is_whitespace))
.map(|pair| (pair.as_span().start() - start, pair.as_span().end() - start))
.collect();
slots.sort_unstable();
// Back to front, so the offsets ahead of each splice stay valid.
let tagged = slots.into_iter().rev().fold(s, |acc, (lo, hi)| {
format!("{}{marker}{}", &acc[..lo], &acc[hi..])
});
Ok(Expr::Geometry(Geometry::Wkt(collapse_whitespace(&tagged))))
}
Rule::Function => {
let mut pairs = primary.into_inner();
// cql2-text is case-insensitive for operator names, but a user-supplied function
// name keeps the case the author wrote.
let op = crate::expr::canonical_op(&strip_quotes(
pairs
.next()
.expect("the grammar guarantees that there is always an op")
.as_str(),
));
let mut args = Vec::new();
for pair in pairs {
args.push(Box::new(parse_expr(pair.into_inner())?))
}
restore_single_element_array(&op, &mut args);
match op.to_lowercase().as_str() {
"interval" => Ok(Expr::Interval { interval: args }),
"date" => Ok(Expr::Date {
date: args
.into_iter()
.next()
.ok_or(Error::MissingArgument("date"))?,
}),
"timestamp" => Ok(Expr::Timestamp {
timestamp: args
.into_iter()
.next()
.ok_or(Error::MissingArgument("timestamp"))?,
}),
"bbox" => Ok(Expr::BBox { bbox: args }),
// The function-call spelling `in(a, 1, 2)` arrives as a flat argument list, but
// `in` is always `[value, array]`, per the JSON schema's `inListOperands`.
"in" => {
let mut args = args.into_iter();
let value = args.next().ok_or(Error::MissingArgument("in"))?;
let list = match args.len() {
1 => match *args.next().expect("length checked above") {
array @ Expr::Array(_) => array,
other => Expr::Array(vec![Box::new(other)]),
},
_ => Expr::Array(args.collect()),
};
Ok(Expr::Operation {
op,
args: vec![value, Box::new(list)],
})
}
_ => Ok(Expr::Operation { op, args }),
}
}
Rule::Array => {
let pairs = primary.into_inner();
let mut array_elements = Vec::new();
for pair in pairs {
array_elements.push(Box::new(parse_expr(pair.into_inner())?))
}
Ok(Expr::Array(array_elements))
}
Rule::Null => Ok(Expr::Null),
rule => unreachable!("parse_expr expected atomic rule, found {:?}", rule),
})
.map_infix(|lhs, op, rhs| {
let lhs = lhs?;
let rhs = rhs?;
// `LIKE` and `IN` carry an optional leading `NOT` as a `NotFlag` child, so the matched
// text spans both words. Take the name from the rule and the negation from the child,
// which keeps both independent of the whitespace between them.
let notflag = op
.clone()
.into_inner()
.next()
.is_some_and(|pair| pair.as_rule() == Rule::NotFlag);
let opstring = match op.as_rule() {
Rule::Like => "like".to_string(),
Rule::In => "in".to_string(),
_ => op.as_str().to_lowercase(),
};
// `(1)` parses as a parenthesized scalar, since the grammar tries grouping before an
// array literal. `in`, the one infix operator whose right operand is a list, restores it.
let rhs = if opstring == "in" && !matches!(rhs, Expr::Array(_)) {
Expr::Array(vec![Box::new(rhs)])
} else {
rhs
};
let retexpr = Expr::Operation {
op: opstring,
args: vec![Box::new(lhs), Box::new(rhs)],
};
if notflag {
return Ok(Expr::Operation {
op: "not".to_string(),
args: vec![Box::new(retexpr)],
});
}
Ok(retexpr)
})
.map_prefix(|op, child| {
let child = child?;
match op.as_rule() {
Rule::UnaryNot => Ok(Expr::Operation {
op: "not".to_string(),
args: vec![Box::new(child)],
}),
Rule::Negative => match child {
// A negated numeric literal is itself a numeric literal, e.g.
// `-2` is `Float(-2.0)`, not `-1 * 2`.
Expr::Float(v) => Ok(Expr::Float(-v)),
_ => Ok(Expr::Operation {
op: "*".to_string(),
args: vec![Box::new(Expr::Float(-1.0)), Box::new(child)],
}),
},
rule => unreachable!("parse_expr expected prefix operator, found {:?}", rule),
}
})
.map_postfix(|child, op| {
let child = child?;
let rule = op.as_rule();
let mut inner = op.into_inner();
// Both postfix predicates carry an optional `NOT` as their first child.
let mut notflag = false;
if inner.peek().map(|pair| pair.as_rule()) == Some(Rule::NotFlag) {
let _ = inner.next();
notflag = true;
}
let retexpr = match rule {
Rule::IsNullPostfix => Expr::Operation {
op: "isNull".to_string(),
args: vec![Box::new(child)],
},
Rule::BetweenPostfix => {
let mut bounds = Vec::with_capacity(2);
for bound in inner {
bounds.push(Box::new(parse_expr(bound.into_inner())?));
}
let [low, high]: [Box<Expr>; 2] = bounds
.try_into()
.map_err(|_| Error::MissingArgument("between"))?;
Expr::Operation {
op: "between".to_string(),
args: vec![Box::new(child), low, high],
}
}
rule => unreachable!("parse_expr expected postfix operator, found {:?}", rule),
};
if notflag {
return Ok(Expr::Operation {
op: "not".to_string(),
args: vec![Box::new(retexpr)],
});
};
Ok(retexpr)
})
.parse(expression_pairs)
}
#[cfg(test)]
mod tests {
use super::{CQL2Parser, Rule};
use crate::Expr;
use pest::Parser;
#[test]
fn point_zm() {
let _ = CQL2Parser::parse(Rule::GEOMETRY, "POINT ZM(-105.1019 40.1672 4981 42)").unwrap();
}
/// A four-ordinate coordinate written without a marker is tagged `ZM`, as a three-ordinate one
/// is tagged `Z`. Untagged, the rendering is text no WKT reader accepts.
#[test]
fn four_dimensional_coordinates_are_tagged() {
for source in [
"s_intersects(geom, POINT(1 2 3 4))",
"s_intersects(geom, POINT ZM(1 2 3 4))",
] {
let text = super::parse_text(source)
.unwrap()
.to_text()
.expect("renders as text");
assert_eq!(text, "s_intersects(geom, POINT ZM(1 2 3 4))");
}
}
#[test]
fn bbox() {
let bbox: Expr =
super::parse_text("bbox(9.978199, 53.541309, 10.010294, 53.557241)").unwrap();
assert!(matches!(bbox, Expr::BBox { .. }));
}
}