Skip to content

Commit 643c41b

Browse files
committed
lib: allow exactly() to take interval ranges as an argument
It turns out that the use case I had for `exactly()` was slightly more subtle, and I need range support. In particular, usage of `exactly()` in expressions used by `revsets.log-graph-prioritize` can and will cause `jj log` to fail if the assertion fails; in my case, a particular expression I use should be equal to zero-or-one revisions. This (ab)uses string literal syntax to parse out a range that is like a subset of Rust's range syntax; in particular unbounded endpoints like `x..` or `..=y` are not supported. Ideally the grammar would be expanded to handle this case more smoothly. Signed-off-by: Austin Seipp <[email protected]>
1 parent df2c1ed commit 643c41b

File tree

5 files changed

+92
-25
lines changed

5 files changed

+92
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6363
deprecation from `jj 0.26`, as the community feedback was mostly negative.
6464

6565
* The revset function `exactly(x, n)` will now evaluate `x` and error if it does
66-
not have exactly `n` elements.
66+
not have the range specified by `n`.
6767

6868
* `jj util exec` now matches the exit status of the program it runs, and
6969
doesn't print anything.

docs/revsets.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,13 @@ revsets (expressions) as arguments.
318318
set are descendants. The current implementation deals somewhat poorly with
319319
non-linear history.
320320

321-
* `exactly(x, count)`: Evaluates `x`, and errors if it is not of exactly size
322-
`count`. Otherwise, returns `x`. This is useful in particular with `count=1`
323-
when you want to ensure that some revset expression has exactly one target.
321+
* `exactly(x, range)`: Evaluates `x`, and errors if it is not within `range`.
322+
Otherwise, returns `x`. This is primarily useful to enforce invariants in
323+
your revsets.
324+
- `range` may be an integer literal, in which case `x` must always resolve
325+
to exactly that number of revisions. e.g. `exactly(root(), 1)`
326+
- `range` may be a half-open interval `n..x`, equivalent to `[n, x)` (`x` is excluded)
327+
- `range` may be a closed interval `n..=x`, equivalent to `[n, x]` (`x` is included)
324328

325329
* `merges()`: Merge commits.
326330

lib/src/default_index/revset_engine.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -994,32 +994,48 @@ impl EvaluationContext<'_> {
994994
let candidate_set = self.evaluate(candidates)?;
995995
Ok(Box::new(self.take_latest_revset(&*candidate_set, *count)?))
996996
}
997-
ResolvedExpression::HasSize { candidates, count } => {
997+
ResolvedExpression::HasSize { candidates, range } => {
998998
let set = self.evaluate(candidates)?;
999+
// Take at most count.end elements to check the range
1000+
// We take one extra to detect if there are more elements than allowed
9991001
let positions: Vec<_> = set
10001002
.positions()
10011003
.attach(index)
1002-
.take(count.saturating_add(1))
1004+
.take(range.end.saturating_add(1))
10031005
.try_collect()?;
1004-
if positions.len() != *count {
1006+
1007+
if !range.contains(&positions.len()) {
10051008
// https://github.com/jj-vcs/jj/pull/7252#pullrequestreview-3236259998
10061009
// in the default engine we have to evaluate the entire
10071010
// revset (which may be very large) to get an exact count;
10081011
// we would need to remove .take() above. instead just give
10091012
// a vaguely approximate error message
1010-
let determiner = if positions.len() > *count {
1011-
"more"
1013+
let expected = if range.start == range.end.saturating_sub(1) {
1014+
// Range represents exactly one value
1015+
format!("exactly {} elements", range.start)
1016+
} else {
1017+
format!(
1018+
"between {} and {} elements",
1019+
range.start,
1020+
range.end.saturating_sub(1)
1021+
)
1022+
};
1023+
1024+
let actual = if positions.len() > range.end {
1025+
"more".to_string()
10121026
} else {
1013-
"less"
1027+
format!("{}", positions.len())
10141028
};
1029+
10151030
return Err(RevsetEvaluationError::Other(
10161031
format!(
1017-
"The revset was expected to have {count} elements, but {determiner} \
1018-
were provided",
1032+
"The revset was expected to have {expected}, but {actual} were \
1033+
provided"
10191034
)
10201035
.into(),
10211036
));
10221037
}
1038+
10231039
Ok(Box::new(EagerRevset { positions }))
10241040
}
10251041
ResolvedExpression::Coalesce(expression1, expression2) => {

lib/src/revset.rs

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ pub enum RevsetExpression<St: ExpressionState> {
298298
Bisect(Arc<Self>),
299299
HasSize {
300300
candidates: Arc<Self>,
301-
count: usize,
301+
range: Range<usize>,
302302
},
303303
Latest {
304304
candidates: Arc<Self>,
@@ -533,10 +533,10 @@ impl<St: ExpressionState> RevsetExpression<St> {
533533
}
534534

535535
/// Commits in `self`, the number of which must be exactly equal to `count`.
536-
pub fn has_size(self: &Arc<Self>, count: usize) -> Arc<Self> {
536+
pub fn has_size(self: &Arc<Self>, range: Range<usize>) -> Arc<Self> {
537537
Arc::new(Self::HasSize {
538538
candidates: self.clone(),
539-
count,
539+
range,
540540
})
541541
}
542542

@@ -736,7 +736,7 @@ pub enum ResolvedExpression {
736736
Bisect(Box<Self>),
737737
HasSize {
738738
candidates: Box<Self>,
739-
count: usize,
739+
range: Range<usize>,
740740
},
741741
Latest {
742742
candidates: Box<Self>,
@@ -948,10 +948,13 @@ static BUILTIN_FUNCTION_MAP: LazyLock<HashMap<&str, RevsetFunction>> = LazyLock:
948948
Ok(RevsetExpression::bisect(&expression))
949949
});
950950
map.insert("exactly", |diagnostics, function, context| {
951-
let ([candidates_arg, count_arg], []) = function.expect_arguments()?;
951+
let ([candidates_arg, range_arg], []) = function.expect_arguments()?;
952952
let candidates = lower_expression(diagnostics, candidates_arg, context)?;
953-
let count = expect_literal("integer", count_arg)?;
954-
Ok(candidates.has_size(count))
953+
let range_str: String = expect_literal("string", range_arg)?;
954+
let range = parse_range(&range_str)
955+
.map_err(|msg| RevsetParseError::expression(msg, range_arg.span))?;
956+
957+
Ok(candidates.has_size(range))
955958
});
956959
map.insert("merges", |_diagnostics, function, _context| {
957960
function.expect_no_arguments()?;
@@ -1161,6 +1164,44 @@ pub fn expect_date_pattern(
11611164
})
11621165
}
11631166

1167+
fn parse_range(s: &str) -> Result<Range<usize>, String> {
1168+
if let Ok(n) = s.parse::<usize>() {
1169+
return Ok(n..n + 1);
1170+
}
1171+
1172+
// inclusive range syntax (a..=b)
1173+
if let Some((start_str, end_str)) = s.split_once("..=") {
1174+
let start = start_str
1175+
.parse::<usize>()
1176+
.map_err(|_| format!("Invalid start of range: {start_str}"))?;
1177+
let end = end_str
1178+
.parse::<usize>()
1179+
.map_err(|_| format!("Invalid end of range: {end_str}"))?;
1180+
if start > end {
1181+
return Err(format!("Invalid range: start ({start}) > end ({end})"));
1182+
}
1183+
return Ok(start..end + 1);
1184+
}
1185+
1186+
// exclusive range syntax (a..b)
1187+
if let Some((start_str, end_str)) = s.split_once("..") {
1188+
let start = start_str
1189+
.parse::<usize>()
1190+
.map_err(|_| format!("Invalid start of range: {start_str}"))?;
1191+
let end = end_str
1192+
.parse::<usize>()
1193+
.map_err(|_| format!("Invalid end of range: {end_str}"))?;
1194+
if start > end {
1195+
return Err(format!("Invalid range: start ({start}) > end ({end})"));
1196+
}
1197+
return Ok(start..end);
1198+
}
1199+
1200+
Err(format!(
1201+
"Invalid range syntax: '{s}'. Expected format: 'n', 'n..m', or 'n..=m'"
1202+
))
1203+
}
1204+
11641205
fn parse_remote_bookmarks_arguments(
11651206
diagnostics: &mut RevsetDiagnostics,
11661207
function: &FunctionCallNode,
@@ -1472,10 +1513,10 @@ fn try_transform_expression<St: ExpressionState, E>(
14721513
RevsetExpression::Bisect(expression) => {
14731514
transform_rec(expression, pre, post)?.map(RevsetExpression::Bisect)
14741515
}
1475-
RevsetExpression::HasSize { candidates, count } => {
1516+
RevsetExpression::HasSize { candidates, range } => {
14761517
transform_rec(candidates, pre, post)?.map(|candidates| RevsetExpression::HasSize {
14771518
candidates,
1478-
count: *count,
1519+
range: range.clone(),
14791520
})
14801521
}
14811522
RevsetExpression::Latest { candidates, count } => transform_rec(candidates, pre, post)?
@@ -1719,11 +1760,11 @@ where
17191760
let expression = folder.fold_expression(expression)?;
17201761
RevsetExpression::Bisect(expression).into()
17211762
}
1722-
RevsetExpression::HasSize { candidates, count } => {
1763+
RevsetExpression::HasSize { candidates, range } => {
17231764
let candidates = folder.fold_expression(candidates)?;
17241765
RevsetExpression::HasSize {
17251766
candidates,
1726-
count: *count,
1767+
range: range.clone(),
17271768
}
17281769
.into()
17291770
}
@@ -3047,9 +3088,9 @@ impl VisibilityResolutionContext<'_> {
30473088
candidates: self.resolve(candidates).into(),
30483089
count: *count,
30493090
},
3050-
RevsetExpression::HasSize { candidates, count } => ResolvedExpression::HasSize {
3091+
RevsetExpression::HasSize { candidates, range } => ResolvedExpression::HasSize {
30513092
candidates: self.resolve(candidates).into(),
3052-
count: *count,
3093+
range: range.clone(),
30533094
},
30543095
RevsetExpression::Filter(_) | RevsetExpression::AsFilter(_) => {
30553096
// Top-level filter without intersection: e.g. "~author(_)" is represented as

lib/tests/test_revset.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3062,6 +3062,7 @@ fn test_evaluate_expression_exactly() {
30623062
let commit1 = write_random_commit(mut_repo);
30633063
let commit2 = write_random_commit_with_parents(mut_repo, &[&commit1]);
30643064

3065+
// basics
30653066
assert!(try_evaluate_expression(mut_repo, "exactly(none(), 0)").is_ok());
30663067
assert!(try_evaluate_expression(mut_repo, "exactly(none(), 1)").is_err());
30673068
assert!(try_evaluate_expression(mut_repo, &format!("exactly({}, 1)", commit1.id())).is_ok());
@@ -3077,6 +3078,11 @@ fn test_evaluate_expression_exactly() {
30773078
resolve_commit_ids(mut_repo, &format!("exactly({}, 1)", commit1.id())),
30783079
vec![commit1.id().clone()]
30793080
);
3081+
// ranges
3082+
assert!(try_evaluate_expression(mut_repo, r#"exactly(none(), "0..=1")"#).is_ok());
3083+
assert!(try_evaluate_expression(mut_repo, r#"exactly(none(), "1..=1")"#).is_err());
3084+
assert!(try_evaluate_expression(mut_repo, r#"exactly(root(), "1..2")"#).is_ok());
3085+
assert!(try_evaluate_expression(mut_repo, r#"exactly(root()::, "1..=3")"#).is_ok());
30803086
}
30813087

30823088
#[test]

0 commit comments

Comments
 (0)