diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a20beaf..76e2d1c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Revsets gained a new function `mine()` that aliases `author([your_email])`. +* `branches()`/`remote_branches()`/`author()`/`committer()`/`description()` + revsets now support literal matching. For example, `branch(literal:main)` + selects the branch named "main", but not "maint". `description(literal:"")` + selects commits whose description is empty. + ### Fixed bugs * `jj config set --user` and `jj config edit --user` can now be used outside of any repository. diff --git a/cli/tests/test_revset_output.rs b/cli/tests/test_revset_output.rs index 326cac168..55643f1ff 100644 --- a/cli/tests/test_revset_output.rs +++ b/cli/tests/test_revset_output.rs @@ -141,6 +141,16 @@ fn test_bad_function_call() { = Invalid file pattern: Path "../out" is not in the repo "###); + let stderr = test_env.jj_cmd_failure(&repo_path, &["log", "-r", "branches(bad:pattern)"]); + insta::assert_snapshot!(stderr, @r###" + Error: Failed to parse revset: --> 1:10 + | + 1 | branches(bad:pattern) + | ^---------^ + | + = Invalid arguments to revset function "branches": Invalid string pattern kind "bad" + "###); + let stderr = test_env.jj_cmd_failure(&repo_path, &["log", "-r", "root::whatever()"]); insta::assert_snapshot!(stderr, @r###" Error: Failed to parse revset: --> 1:7 @@ -303,7 +313,7 @@ fn test_alias() { 1 | author(x) | ^ | - = Invalid arguments to revset function "author": Expected function argument of type string + = Invalid arguments to revset function "author": Expected function argument of string pattern "###); let stderr = test_env.jj_cmd_failure(&repo_path, &["log", "-r", "root & recurse"]); diff --git a/docs/revsets.md b/docs/revsets.md index e8a31d49e..b32e97651 100644 --- a/docs/revsets.md +++ b/docs/revsets.md @@ -137,6 +137,13 @@ revsets (expressions) as arguments. * `present(x)`: Same as `x`, but evaluated to `none()` if any of the commits in `x` doesn't exist (e.g. is an unknown branch name.) +## String patterns + +Functions that perform string matching support the following pattern syntax. + +* `"substring"`: Matches strings that contain `substring`. +* `literal:"string"`: Matches strings exactly equal to `string`. + ## Aliases New symbols and functions can be defined in the config file, by using any diff --git a/lib/src/revset.rs b/lib/src/revset.rs index 59cc2e55c..8bed26cbe 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -212,6 +212,8 @@ pub const GENERATION_RANGE_EMPTY: Range = 0..0; /// branch name. #[derive(Clone, Debug, Eq, PartialEq)] pub enum StringPattern { + /// Matches strings exactly equal to `string`. + Literal(String), /// Matches strings that contain `substring`. Substring(String), } @@ -225,6 +227,7 @@ impl StringPattern { /// Returns true if this pattern matches the `haystack`. pub fn matches(&self, haystack: &str) -> bool { match self { + StringPattern::Literal(literal) => haystack == literal, StringPattern::Substring(needle) => haystack.contains(needle), } } @@ -1280,8 +1283,51 @@ fn parse_function_argument_to_string_pattern( pair: Pair, state: ParseState, ) -> Result { - let needle = parse_function_argument_as_literal("string", name, pair, state)?; - Ok(StringPattern::Substring(needle)) + let span = pair.as_span(); + let make_error = |message| { + RevsetParseError::with_span( + RevsetParseErrorKind::InvalidFunctionArguments { + name: name.to_string(), + message, + }, + span, + ) + }; + let make_type_error = || make_error("Expected function argument of string pattern".to_owned()); + let expression = parse_expression_rule(pair.into_inner(), state)?; + let pattern = match expression.as_ref() { + RevsetExpression::CommitRef(RevsetCommitRef::Symbol(symbol)) => { + let needle = symbol.to_owned(); + StringPattern::Substring(needle) + } + // TODO: Add proper parsed node if we drop support for legacy x:y range + RevsetExpression::DagRange { + roots, + heads, + is_legacy: true, + } => { + // TODO: quoted string shouldn't be allowed as a pattern kind + let RevsetExpression::CommitRef(RevsetCommitRef::Symbol(kind)) = roots.as_ref() else { + return Err(make_type_error()); + }; + let RevsetExpression::CommitRef(RevsetCommitRef::Symbol(needle)) = heads.as_ref() + else { + return Err(make_type_error()); + }; + match kind.as_ref() { + "literal" => StringPattern::Literal(needle.clone()), + // TODO: maybe add explicit kind for substring match? + _ => { + // TODO: error span can be narrowed to the lhs node + return Err(make_error(format!( + r#"Invalid string pattern kind "{kind}""# + ))); + } + } + } + _ => return Err(make_type_error()), + }; + Ok(pattern) } fn parse_function_argument_as_literal( @@ -2621,6 +2667,42 @@ mod tests { ); } + #[test] + fn test_parse_string_pattern() { + assert_eq!( + parse(r#"branches("foo")"#), + Ok(RevsetExpression::branches(StringPattern::Substring( + "foo".to_owned() + ))) + ); + assert_eq!( + parse(r#"branches(literal:"foo")"#), + Ok(RevsetExpression::branches(StringPattern::Literal( + "foo".to_owned() + ))) + ); + assert_eq!( + parse(r#"branches("literal:foo")"#), + Ok(RevsetExpression::branches(StringPattern::Substring( + "literal:foo".to_owned() + ))) + ); + assert_eq!( + parse(r#"branches(bad:"foo")"#), + Err(RevsetParseErrorKind::InvalidFunctionArguments { + name: "branches".to_owned(), + message: r#"Invalid string pattern kind "bad""#.to_owned() + }) + ); + assert_eq!( + parse(r#"branches(literal::"foo")"#), + Err(RevsetParseErrorKind::InvalidFunctionArguments { + name: "branches".to_owned(), + message: "Expected function argument of string pattern".to_owned() + }) + ); + } + #[test] fn test_parse_revset_alias_formal_parameter() { let mut aliases_map = RevsetAliasesMap::new(); @@ -2743,7 +2825,7 @@ mod tests { parse("description(visible_heads())"), Err(RevsetParseErrorKind::InvalidFunctionArguments { name: "description".to_string(), - message: "Expected function argument of type string".to_string() + message: "Expected function argument of string pattern".to_string() }) ); assert_eq!( @@ -2857,10 +2939,24 @@ mod tests { ); // Alias can be substituted to string literal. + assert_eq!( + parse_with_aliases("file(A)", [("A", "a")]).unwrap(), + parse("file(a)").unwrap() + ); + + // Alias can be substituted to string pattern. assert_eq!( parse_with_aliases("author(A)", [("A", "a")]).unwrap(), parse("author(a)").unwrap() ); + assert_eq!( + parse_with_aliases("author(A)", [("A", "literal:a")]).unwrap(), + parse("author(literal:a)").unwrap() + ); + assert_eq!( + parse_with_aliases("author(literal:A)", [("A", "a")]).unwrap(), + parse("author(literal:a)").unwrap() + ); // Multi-level substitution. assert_eq!( diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs index 062facdfc..6590108de 100644 --- a/lib/tests/test_revset.rs +++ b/lib/tests/test_revset.rs @@ -1716,8 +1716,16 @@ fn test_evaluate_expression_branches(use_git: bool) { resolve_commit_ids(mut_repo, "branches(branch)"), vec![commit2.id().clone(), commit1.id().clone()] ); + assert_eq!( + resolve_commit_ids(mut_repo, "branches(literal:branch1)"), + vec![commit1.id().clone()] + ); // Can silently resolve to an empty set if there's no matches assert_eq!(resolve_commit_ids(mut_repo, "branches(branch3)"), vec![]); + assert_eq!( + resolve_commit_ids(mut_repo, "branches(literal:ranch1)"), + vec![] + ); // Two branches pointing to the same commit does not result in a duplicate in // the revset mut_repo.set_local_branch_target("branch3", RefTarget::normal(commit2.id().clone())); @@ -1788,6 +1796,10 @@ fn test_evaluate_expression_remote_branches(use_git: bool) { resolve_commit_ids(mut_repo, "remote_branches(branch)"), vec![commit2.id().clone(), commit1.id().clone()] ); + assert_eq!( + resolve_commit_ids(mut_repo, "remote_branches(literal:branch1)"), + vec![commit1.id().clone()] + ); // Can get branches from matching remotes assert_eq!( resolve_commit_ids(mut_repo, r#"remote_branches("", origin)"#), @@ -1797,6 +1809,10 @@ fn test_evaluate_expression_remote_branches(use_git: bool) { resolve_commit_ids(mut_repo, r#"remote_branches("", ri)"#), vec![commit2.id().clone(), commit1.id().clone()] ); + assert_eq!( + resolve_commit_ids(mut_repo, r#"remote_branches("", literal:origin)"#), + vec![commit1.id().clone()] + ); // Can get branches with matching names from matching remotes assert_eq!( resolve_commit_ids(mut_repo, "remote_branches(branch1, ri)"), @@ -1806,6 +1822,13 @@ fn test_evaluate_expression_remote_branches(use_git: bool) { resolve_commit_ids(mut_repo, r#"remote_branches(branch, private)"#), vec![commit2.id().clone()] ); + assert_eq!( + resolve_commit_ids( + mut_repo, + r#"remote_branches(literal:branch1, literal:origin)"# + ), + vec![commit1.id().clone()] + ); // Can silently resolve to an empty set if there's no matches assert_eq!( resolve_commit_ids(mut_repo, "remote_branches(branch3)"), @@ -1819,6 +1842,20 @@ fn test_evaluate_expression_remote_branches(use_git: bool) { resolve_commit_ids(mut_repo, r#"remote_branches(branch1, private)"#), vec![] ); + assert_eq!( + resolve_commit_ids( + mut_repo, + r#"remote_branches(literal:ranch1, literal:origin)"# + ), + vec![] + ); + assert_eq!( + resolve_commit_ids( + mut_repo, + r#"remote_branches(literal:branch1, literal:orig)"# + ), + vec![] + ); // Two branches pointing to the same commit does not result in a duplicate in // the revset mut_repo.set_remote_branch_target("branch3", "origin", RefTarget::normal(commit2.id().clone()));