Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! A deliberately small revset subset (spec §8).
2//!
3//! > Do not attempt to implement jj's full revset language against a Git-backed
4//! > index; it will not be faithful, and a subtly wrong revset is worse than an
5//! > honestly limited one. Show a clear "unsupported expression" message rather
6//! > than silently returning partial results.
7//!
8//! Supported: `author()`, `description()`, `conflicted()`, `bookmarks()`,
9//! `open()`, and the operators `&`, `|`, `~`.
10//!
11//! `docs/revset-semantics.md` records the trap this parser is built around: in
12//! jj, `description("x")` is an **exact** match, not a substring match. The
13//! obvious `ILIKE '%x%'` translation returns *more* rows than the CLI would, so
14//! the bare form is rejected and `description(substring:"x")` is required to
15//! ask for substring matching.
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Expr {
19 Author(String),
20 /// Exact match on the full description.
21 DescriptionExact(String),
22 /// Substring match, only via the explicit `substring:` prefix.
23 DescriptionSubstring(String),
24 Conflicted,
25 Open,
26 Bookmark(String),
27 And(Box<Expr>, Box<Expr>),
28 Or(Box<Expr>, Box<Expr>),
29 Not(Box<Expr>),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RevsetError(pub String);
34
35impl std::fmt::Display for RevsetError {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.write_str(&self.0)
38 }
39}
40
41/// Functions jj has that this subset deliberately does not implement.
42///
43/// Named explicitly so the message says *which* function is unsupported rather
44/// than producing a generic syntax error.
45const KNOWN_UNSUPPORTED: &[&str] = &[
46 "heads", "roots", "ancestors", "descendants", "parents", "children",
47 "latest", "merges", "file", "diff_contains", "empty", "tags", "git_refs",
48 "visible_heads", "reachable", "connected", "mine", "trunk", "present",
49];
50
51pub fn parse(input: &str) -> Result<Option<Expr>, RevsetError> {
52 let trimmed = input.trim();
53 if trimmed.is_empty() {
54 return Ok(None);
55 }
56 if trimmed.len() > 500 {
57 return Err(RevsetError("Expression is too long.".into()));
58 }
59
60 let mut p = Parser {
61 s: trimmed.as_bytes(),
62 i: 0,
63 };
64 let e = p.parse_or()?;
65 p.skip_ws();
66 if p.i < p.s.len() {
67 return Err(RevsetError(format!(
68 "Unexpected input at position {}.",
69 p.i
70 )));
71 }
72 Ok(Some(e))
73}
74
75struct Parser<'a> {
76 s: &'a [u8],
77 i: usize,
78}
79
80impl<'a> Parser<'a> {
81 fn skip_ws(&mut self) {
82 while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
83 self.i += 1;
84 }
85 }
86
87 fn peek(&mut self) -> Option<u8> {
88 self.skip_ws();
89 self.s.get(self.i).copied()
90 }
91
92 fn parse_or(&mut self) -> Result<Expr, RevsetError> {
93 let mut left = self.parse_and()?;
94 while self.peek() == Some(b'|') {
95 self.i += 1;
96 let right = self.parse_and()?;
97 left = Expr::Or(Box::new(left), Box::new(right));
98 }
99 Ok(left)
100 }
101
102 fn parse_and(&mut self) -> Result<Expr, RevsetError> {
103 let mut left = self.parse_unary()?;
104 while self.peek() == Some(b'&') {
105 self.i += 1;
106 let right = self.parse_unary()?;
107 left = Expr::And(Box::new(left), Box::new(right));
108 }
109 Ok(left)
110 }
111
112 fn parse_unary(&mut self) -> Result<Expr, RevsetError> {
113 if self.peek() == Some(b'~') {
114 self.i += 1;
115 return Ok(Expr::Not(Box::new(self.parse_unary()?)));
116 }
117 self.parse_atom()
118 }
119
120 fn parse_atom(&mut self) -> Result<Expr, RevsetError> {
121 match self.peek() {
122 Some(b'(') => {
123 self.i += 1;
124 let e = self.parse_or()?;
125 if self.peek() != Some(b')') {
126 return Err(RevsetError("Missing closing parenthesis.".into()));
127 }
128 self.i += 1;
129 Ok(e)
130 }
131 Some(c) if c.is_ascii_alphabetic() || c == b'_' => self.parse_call(),
132 Some(c) => Err(RevsetError(format!(
133 "Unexpected character '{}'.",
134 c as char
135 ))),
136 None => Err(RevsetError("Unexpected end of expression.".into())),
137 }
138 }
139
140 fn parse_call(&mut self) -> Result<Expr, RevsetError> {
141 self.skip_ws();
142 let start = self.i;
143 while self.i < self.s.len()
144 && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_')
145 {
146 self.i += 1;
147 }
148 let name = std::str::from_utf8(&self.s[start..self.i])
149 .map_err(|_| RevsetError("Invalid characters in function name.".into()))?
150 .to_string();
151
152 if self.peek() != Some(b'(') {
153 return Err(RevsetError(format!(
154 "`{name}` is not a value. This subset supports only function calls \
155 such as author(…), description(…), conflicted(), open(), bookmarks(…)."
156 )));
157 }
158 self.i += 1;
159
160 let arg = self.parse_arg()?;
161
162 if self.peek() != Some(b')') {
163 return Err(RevsetError(format!("Missing closing parenthesis after `{name}(`.")));
164 }
165 self.i += 1;
166
167 build(&name, arg)
168 }
169
170 /// Read a function argument: a quoted string, a `prefix:"value"` form, or a
171 /// bare word.
172 fn parse_arg(&mut self) -> Result<String, RevsetError> {
173 self.skip_ws();
174 if self.peek() == Some(b')') {
175 return Ok(String::new());
176 }
177
178 let mut out = String::new();
179 // Optional `substring:` / `exact:` prefix, kept verbatim for `build`.
180 let start = self.i;
181 while self.i < self.s.len()
182 && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_')
183 {
184 self.i += 1;
185 }
186 if self.i < self.s.len() && self.s[self.i] == b':' {
187 out.push_str(
188 std::str::from_utf8(&self.s[start..self.i])
189 .map_err(|_| RevsetError("Invalid argument.".into()))?,
190 );
191 out.push(':');
192 self.i += 1;
193 } else {
194 self.i = start;
195 }
196
197 self.skip_ws();
198 match self.s.get(self.i) {
199 Some(b'"') | Some(b'\'') => {
200 let quote = self.s[self.i];
201 self.i += 1;
202 let vstart = self.i;
203 while self.i < self.s.len() && self.s[self.i] != quote {
204 self.i += 1;
205 }
206 if self.i >= self.s.len() {
207 return Err(RevsetError("Unterminated string.".into()));
208 }
209 out.push_str(
210 std::str::from_utf8(&self.s[vstart..self.i])
211 .map_err(|_| RevsetError("Invalid string.".into()))?,
212 );
213 self.i += 1;
214 }
215 _ => {
216 let vstart = self.i;
217 while self.i < self.s.len()
218 && self.s[self.i] != b')'
219 && self.s[self.i] != b'&'
220 && self.s[self.i] != b'|'
221 {
222 self.i += 1;
223 }
224 out.push_str(
225 std::str::from_utf8(&self.s[vstart..self.i])
226 .map_err(|_| RevsetError("Invalid argument.".into()))?
227 .trim(),
228 );
229 }
230 }
231 Ok(out)
232 }
233}
234
235fn build(name: &str, arg: String) -> Result<Expr, RevsetError> {
236 match name {
237 "author" => {
238 if arg.is_empty() {
239 return Err(RevsetError("author() needs an argument.".into()));
240 }
241 Ok(Expr::Author(strip_prefix(&arg)))
242 }
243 "description" | "subject" => {
244 if let Some(v) = arg.strip_prefix("substring:") {
245 Ok(Expr::DescriptionSubstring(v.to_string()))
246 } else if let Some(v) = arg.strip_prefix("exact:") {
247 Ok(Expr::DescriptionExact(v.to_string()))
248 } else {
249 // The trap. jj matches exactly here; a substring translation
250 // would return more than the CLI does.
251 Err(RevsetError(format!(
252 "`{name}(\"\")` matches the description EXACTLY in jj, which rarely \
253 does what you want here. Write {name}(substring:\"\") for a \
254 substring match, or {name}(exact:\"\") to be explicit."
255 )))
256 }
257 }
258 "conflicted" | "conflicts" => Ok(Expr::Conflicted),
259 "open" => Ok(Expr::Open),
260 "bookmarks" | "bookmark" | "branches" => Ok(Expr::Bookmark(strip_prefix(&arg))),
261 other if KNOWN_UNSUPPORTED.contains(&other) => Err(RevsetError(format!(
262 "`{other}()` needs graph traversal or content search, which this index \
263 cannot answer faithfully. Supported: author, description, conflicted, \
264 open, bookmarks."
265 ))),
266 other => Err(RevsetError(format!(
267 "Unknown function `{other}()`. Supported: author, description, \
268 conflicted, open, bookmarks."
269 ))),
270 }
271}
272
273fn strip_prefix(arg: &str) -> String {
274 arg.strip_prefix("substring:")
275 .or_else(|| arg.strip_prefix("exact:"))
276 .unwrap_or(arg)
277 .to_string()
278}
279
280/// Compile to a SQL fragment plus its bind values.
281///
282/// Placeholders start at `next_param`, which the caller advances so the
283/// fragment can be spliced into a larger query.
284/// Escape the wildcards in a value destined for `ILIKE`.
285///
286/// The value is bound as a parameter, so this is not about SQL injection — it
287/// is about meaning. `author(%)` would otherwise match every author, and
288/// `description-substring(a_b)` would match `axb`, neither of which is what the
289/// user wrote. `\` is the escape character Postgres uses by default.
290fn escape_like(v: &str) -> String {
291 let mut out = String::with_capacity(v.len());
292 for c in v.chars() {
293 if matches!(c, '%' | '_' | '\\') {
294 out.push('\\');
295 }
296 out.push(c);
297 }
298 out
299}
300
301pub fn to_sql(e: &Expr, next_param: &mut usize) -> (String, Vec<String>) {
302 match e {
303 Expr::Author(v) => {
304 // jj matches author name *and* email as a substring.
305 let a = *next_param;
306 *next_param += 1;
307 (
308 format!(
309 "EXISTS (SELECT 1 FROM revisions rv WHERE rv.change_id_fk = c.id \
310 AND (rv.author_name ILIKE '%' || ${a} || '%' \
311 OR rv.author_email ILIKE '%' || ${a} || '%'))"
312 ),
313 vec![escape_like(v)],
314 )
315 }
316 Expr::DescriptionExact(v) => {
317 let a = *next_param;
318 *next_param += 1;
319 (format!("(c.title = ${a} OR c.description = ${a})"), vec![v.clone()])
320 }
321 Expr::DescriptionSubstring(v) => {
322 let a = *next_param;
323 *next_param += 1;
324 (
325 format!("(c.title ILIKE '%' || ${a} || '%' OR c.description ILIKE '%' || ${a} || '%')"),
326 vec![escape_like(v)],
327 )
328 }
329 Expr::Conflicted => ("c.conflicted".to_string(), vec![]),
330 Expr::Open => ("c.state = 'open'".to_string(), vec![]),
331 Expr::Bookmark(v) => {
332 let a = *next_param;
333 *next_param += 1;
334 (format!("c.target_bookmark = ${a}"), vec![v.clone()])
335 }
336 Expr::And(l, r) => {
337 let (ls, mut lv) = to_sql(l, next_param);
338 let (rs, rv) = to_sql(r, next_param);
339 lv.extend(rv);
340 (format!("({ls} AND {rs})"), lv)
341 }
342 Expr::Or(l, r) => {
343 let (ls, mut lv) = to_sql(l, next_param);
344 let (rs, rv) = to_sql(r, next_param);
345 lv.extend(rv);
346 (format!("({ls} OR {rs})"), lv)
347 }
348 Expr::Not(i) => {
349 let (s, v) = to_sql(i, next_param);
350 (format!("(NOT {s})"), v)
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn empty_input_is_no_filter() {
361 assert_eq!(parse(""), Ok(None));
362 assert_eq!(parse(" "), Ok(None));
363 }
364
365 #[test]
366 fn parses_the_supported_functions() {
367 assert_eq!(parse("conflicted()").unwrap(), Some(Expr::Conflicted));
368 assert_eq!(parse("open()").unwrap(), Some(Expr::Open));
369 assert_eq!(
370 parse("author(mira)").unwrap(),
371 Some(Expr::Author("mira".into()))
372 );
373 assert_eq!(
374 parse("author(\"mira\")").unwrap(),
375 Some(Expr::Author("mira".into()))
376 );
377 assert_eq!(
378 parse("bookmarks(main)").unwrap(),
379 Some(Expr::Bookmark("main".into()))
380 );
381 }
382
383 // ─── the description trap (docs/revset-semantics.md) ─────────────────────
384
385 #[test]
386 fn bare_description_is_rejected_rather_than_silently_made_substring() {
387 // jj's description("x") is an EXACT match. Translating it to ILIKE
388 // would return more rows than the CLI, which §8 calls worse than an
389 // honest limitation.
390 let e = parse("description(\"fix\")").unwrap_err();
391 assert!(e.0.contains("EXACTLY"), "message must explain: {}", e.0);
392 assert!(e.0.contains("substring:"), "must suggest the fix: {}", e.0);
393 }
394
395 #[test]
396 fn explicit_substring_and_exact_forms_are_accepted() {
397 assert_eq!(
398 parse("description(substring:\"fix\")").unwrap(),
399 Some(Expr::DescriptionSubstring("fix".into()))
400 );
401 assert_eq!(
402 parse("description(exact:\"fix\")").unwrap(),
403 Some(Expr::DescriptionExact("fix".into()))
404 );
405 }
406
407 // ─── operators ───────────────────────────────────────────────────────────
408
409 #[test]
410 fn parses_and_or_not() {
411 let e = parse("author(a) & conflicted()").unwrap().unwrap();
412 assert!(matches!(e, Expr::And(_, _)));
413
414 let e = parse("open() | conflicted()").unwrap().unwrap();
415 assert!(matches!(e, Expr::Or(_, _)));
416
417 let e = parse("~conflicted()").unwrap().unwrap();
418 assert!(matches!(e, Expr::Not(_)));
419 }
420
421 #[test]
422 fn and_binds_tighter_than_or() {
423 // a | b & c == a | (b & c)
424 let e = parse("open() | conflicted() & author(x)").unwrap().unwrap();
425 match e {
426 Expr::Or(l, r) => {
427 assert_eq!(*l, Expr::Open);
428 assert!(matches!(*r, Expr::And(_, _)));
429 }
430 other => panic!("expected Or at the top, got {other:?}"),
431 }
432 }
433
434 #[test]
435 fn parentheses_override_precedence() {
436 let e = parse("(open() | conflicted()) & author(x)").unwrap().unwrap();
437 assert!(matches!(e, Expr::And(_, _)));
438 }
439
440 // ─── honest rejection (spec §8) ──────────────────────────────────────────
441
442 #[test]
443 fn unsupported_functions_are_named_in_the_error() {
444 for f in ["heads()", "ancestors(x)", "file(a.rs)", "merges()"] {
445 let e = parse(f).unwrap_err();
446 let name = f.split('(').next().unwrap();
447 assert!(
448 e.0.contains(name),
449 "error for {f} must name the function: {}",
450 e.0
451 );
452 }
453 }
454
455 #[test]
456 fn unknown_functions_are_rejected_not_ignored() {
457 let e = parse("authr(mira)").unwrap_err();
458 assert!(e.0.contains("authr"), "got {}", e.0);
459 }
460
461 #[test]
462 fn malformed_input_produces_an_error_not_a_partial_result() {
463 for bad in [
464 "authr(mira) & (",
465 "open(",
466 ")",
467 "&",
468 "author(",
469 "open() &",
470 "\"unterminated",
471 ] {
472 assert!(parse(bad).is_err(), "{bad:?} should not parse");
473 }
474 }
475
476 #[test]
477 fn a_bare_word_is_rejected_with_a_helpful_message() {
478 let e = parse("main").unwrap_err();
479 assert!(e.0.contains("not a value"), "got {}", e.0);
480 }
481
482 #[test]
483 fn absurdly_long_input_is_refused() {
484 assert!(parse(&"open() | ".repeat(200)).is_err());
485 }
486
487 // ─── SQL generation ──────────────────────────────────────────────────────
488
489 #[test]
490 fn generates_numbered_placeholders_without_interpolating_values() {
491 let e = parse("author(mira) & conflicted()").unwrap().unwrap();
492 let mut n = 3;
493 let (sql, vals) = to_sql(&e, &mut n);
494
495 assert!(sql.contains("$3"), "got {sql}");
496 assert_eq!(vals, vec!["mira".to_string()]);
497 assert_eq!(n, 4, "the parameter counter must advance");
498 // The value must never appear inline — that would be an injection.
499 assert!(!sql.contains("mira"), "value was interpolated into SQL: {sql}");
500 }
501
502 #[test]
503 fn a_hostile_value_stays_a_bind_parameter() {
504 let e = parse("author(\"'; DROP TABLE changes; --\")").unwrap().unwrap();
505 let mut n = 1;
506 let (sql, vals) = to_sql(&e, &mut n);
507 assert!(!sql.contains("DROP"), "SQL injection: {sql}");
508 assert_eq!(vals[0], "'; DROP TABLE changes; --");
509 }
510
511 #[test]
512 fn placeholders_stay_unique_across_a_compound_expression() {
513 let e = parse("author(a) & (bookmarks(b) | description(substring:\"c\"))")
514 .unwrap()
515 .unwrap();
516 let mut n = 1;
517 let (sql, vals) = to_sql(&e, &mut n);
518 assert_eq!(vals.len(), 3);
519 for i in 1..=3 {
520 assert!(sql.contains(&format!("${i}")), "missing ${i} in {sql}");
521 }
522 assert_eq!(n, 4);
523 }
524
525 #[test]
526 fn like_wildcards_in_a_value_are_escaped() {
527 // Bound as a parameter either way, so this is about meaning rather than
528 // injection: `author(%)` must not match every author.
529 assert_eq!(escape_like("%"), "\\%");
530 assert_eq!(escape_like("a_b"), "a\\_b");
531 assert_eq!(escape_like("100%_sure"), "100\\%\\_sure");
532 assert_eq!(escape_like("plain"), "plain");
533 assert_eq!(escape_like("back\\slash"), "back\\\\slash");
534 }
535
536 #[test]
537 fn escaped_values_reach_the_bind_list() {
538 let e = parse("author(%)").unwrap().unwrap();
539 let mut n = 3;
540 let (_, vals) = to_sql(&e, &mut n);
541 assert_eq!(vals, vec!["\\%".to_string()]);
542 }
543}

543 lines · Rust