From 9b691f30535b5a7a0b15d9eb88a6af22fc20159e Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Fri, 1 May 2026 16:54:33 +0200 Subject: [PATCH 1/8] crates: Add the `jj-core` crate This will be common base for building upon `jj` if you only want the internals which make the other systems work, like the `Backend` or `WorkingCopy` trait. It should be of utmost importance to make the crate as low dependency as possible so its not in the critical path during compilation. Part of #6284 --- Cargo.lock | 4 ++++ Cargo.toml | 2 +- lib/core/Cargo.toml | 17 +++++++++++++++++ lib/core/src/lib.rs | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 lib/core/Cargo.toml create mode 100644 lib/core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c639a9852e8..dc162d8dbe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2635,6 +2635,10 @@ dependencies = [ "whoami", ] +[[package]] +name = "jj-core" +version = "0.43.0" + [[package]] name = "jj-lib" version = "0.43.0" diff --git a/Cargo.toml b/Cargo.toml index ebbdfbc26eb..3ff6ca72bf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ cargo-features = [] [workspace] resolver = "3" -members = ["cli", "lib", "lib/gen-protos", "lib/proc-macros", "lib/testutils"] +members = ["cli", "lib", "lib/core", "lib/gen-protos", "lib/proc-macros", "lib/testutils"] [workspace.package] version = "0.43.0" diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml new file mode 100644 index 00000000000..ff3a82422fe --- /dev/null +++ b/lib/core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jj-core" +version.workspace = true +license.workspace = true +rust-version.workspace = true +edition.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +documentation.workspace = true +categories.workspace = true +keywords.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs new file mode 100644 index 00000000000..02eb60d1a21 --- /dev/null +++ b/lib/core/src/lib.rs @@ -0,0 +1,20 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The core library powering the Jujutsu Version Control System. It contains +//! all "base" types such as `Commit` and the `Backend` trait. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] +#![deny(unused_must_use)] From 6660ca3373c1d47856bf54fe6a85fe628eb74102 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Wed, 3 Jun 2026 19:32:02 +0200 Subject: [PATCH 2/8] core: Move `content_hash` and `repo_path` into it This part of the library is quite basic and it doesn't have too many dependencies which also need to move to make it happen. We also need to move the `ContentHash` macro since all `RepoPath` types depend on it, the macro is moved to a new `jj-core-proc-macros` crate which mirrors the existing structure in `jj-lib`. Since we now have `jj-core-proc-macros` this also deprecates `jj-lib-proc-macros` for external consumers. Part of #6284 --- Cargo.lock | 40 +- Cargo.toml | 2 + lib/Cargo.toml | 3 +- lib/core/Cargo.toml | 26 + lib/core/proc-macros/Cargo.toml | 26 + lib/core/proc-macros/LICENSE | 202 +++ .../proc-macros/src/content_hash.rs | 12 +- lib/core/proc-macros/src/lib.rs | 38 + lib/core/src/content_hash.rs | 282 ++++ lib/core/src/file_util.rs | 688 ++++++++++ lib/core/src/lib.rs | 30 + lib/core/src/repo_path.rs | 1170 +++++++++++++++++ lib/proc-macros/Cargo.toml | 5 +- lib/proc-macros/src/lib.rs | 40 +- lib/src/content_hash.rs | 260 +--- lib/src/repo_path.rs | 1148 +--------------- 16 files changed, 2525 insertions(+), 1447 deletions(-) create mode 100644 lib/core/proc-macros/Cargo.toml create mode 100644 lib/core/proc-macros/LICENSE rename lib/{ => core}/proc-macros/src/content_hash.rs (90%) create mode 100644 lib/core/proc-macros/src/lib.rs create mode 100644 lib/core/src/content_hash.rs create mode 100644 lib/core/src/file_util.rs create mode 100644 lib/core/src/repo_path.rs diff --git a/Cargo.lock b/Cargo.lock index dc162d8dbe6..687ac0c6641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,38 @@ dependencies = [ [[package]] name = "jj-core" version = "0.43.0" +dependencies = [ + "assert_matches", + "blake2", + "chrono", + "clru", + "digest 0.10.7", + "etcetera", + "eyre", + "futures 0.3.33", + "itertools 0.15.0", + "jj-core-proc-macros", + "pollster", + "ref-cast", + "same-file", + "serde", + "smallvec", + "tempfile", + "test-case", + "thiserror 2.0.19", + "tokio", + "tracing", + "winreg", +] + +[[package]] +name = "jj-core-proc-macros" +version = "0.43.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "jj-lib" @@ -2665,7 +2697,8 @@ dependencies = [ "insta", "interim", "itertools 0.15.0", - "jj-lib-proc-macros", + "jj-core", + "jj-core-proc-macros", "maplit", "memchr", "nix 0.31.3", @@ -2703,11 +2736,6 @@ dependencies = [ [[package]] name = "jj-lib-proc-macros" version = "0.43.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] [[package]] name = "js-sys" diff --git a/Cargo.toml b/Cargo.toml index 3ff6ca72bf0..61fff11c8e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,8 @@ winreg = "0.56" # put all inter-workspace libraries, i.e. those that use 'path = ...' here in # their own (alphabetically sorted) block +jj-core = { path = "lib/core", version = "0.43.0", default-features = false } +jj-core-proc-macros = { path = "lib/core/proc-macros", version = "0.43.0" } jj-lib = { path = "lib", version = "0.43.0", default-features = false } jj-lib-proc-macros = { path = "lib/proc-macros", version = "0.43.0" } testutils = { path = "lib/testutils" } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 152fc1700c5..9f9da3e3849 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -47,7 +47,8 @@ hashbrown = { workspace = true } indexmap = { workspace = true } interim = { workspace = true } itertools = { workspace = true } -jj-lib-proc-macros = { workspace = true } +jj-core = { workspace = true } +jj-core-proc-macros = { workspace = true } maplit = { workspace = true } memchr = { workspace = true } once_cell = { workspace = true } diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml index ff3a82422fe..d15ce15196a 100644 --- a/lib/core/Cargo.toml +++ b/lib/core/Cargo.toml @@ -13,5 +13,31 @@ keywords.workspace = true [dependencies] +blake2 = { workspace = true } +chrono = { workspace = true } +clru = { workspace = true } +digest = { workspace = true } +etcetera = { workspace = true } +futures = { workspace = true } +itertools = { workspace = true } +jj-core-proc-macros = { workspace = true } +pollster = { workspace = true } +ref-cast = { workspace = true } +serde = { workspace = true } +smallvec = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +test-case = { workspace = true } +eyre = { workspace = true } + +[target.'cfg(windows)'.dependencies] +same-file = { workspace = true } +winreg = { workspace = true } + [lints] workspace = true diff --git a/lib/core/proc-macros/Cargo.toml b/lib/core/proc-macros/Cargo.toml new file mode 100644 index 00000000000..8c06760c634 --- /dev/null +++ b/lib/core/proc-macros/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "jj-core-proc-macros" +description = "Proc macros for the jj-core crate" + +categories.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +include = ["/LICENSE", "/src/"] + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + +[lints] +workspace = true diff --git a/lib/core/proc-macros/LICENSE b/lib/core/proc-macros/LICENSE new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/lib/core/proc-macros/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lib/proc-macros/src/content_hash.rs b/lib/core/proc-macros/src/content_hash.rs similarity index 90% rename from lib/proc-macros/src/content_hash.rs rename to lib/core/proc-macros/src/content_hash.rs index e7d3d31b9ec..2123d143661 100644 --- a/lib/proc-macros/src/content_hash.rs +++ b/lib/core/proc-macros/src/content_hash.rs @@ -18,7 +18,7 @@ pub fn add_trait_bounds(mut generics: Generics) -> Generics { if let GenericParam::Type(type_param) = param { type_param .bounds - .push(parse_quote!(::jj_lib::content_hash::ContentHash)); + .push(parse_quote!(::jj_core::content_hash::ContentHash)); } } generics @@ -32,7 +32,7 @@ pub fn generate_hash_impl(data: &Data) -> TokenStream { let field_name = &f.ident; let ty = &f.ty; quote_spanned! {ty.span()=> - <#ty as ::jj_lib::content_hash::ContentHash>::hash( + <#ty as ::jj_core::content_hash::ContentHash>::hash( &self.#field_name, state); } }); @@ -45,7 +45,7 @@ pub fn generate_hash_impl(data: &Data) -> TokenStream { let index = Index::from(i); let ty = &f.ty; quote_spanned! {ty.span() => - <#ty as ::jj_lib::content_hash::ContentHash>::hash(&self.#index, state); + <#ty as ::jj_core::content_hash::ContentHash>::hash(&self.#index, state); } }); quote! { @@ -86,7 +86,7 @@ pub fn generate_hash_impl(data: &Data) -> TokenStream { let ix = index_to_ordinal(i); quote_spanned! {v.span() => Self::#variant_id => { - ::jj_lib::content_hash::ContentHash::hash(&#ix, state); + ::jj_core::content_hash::ContentHash::hash(&#ix, state); } } } @@ -137,10 +137,10 @@ fn hash_statements_for_enum_fields<'a>( let ix = index_to_ordinal(index); let typed_bindings = enum_bindings_with_type(fields); let mut hash_statements = Vec::with_capacity(typed_bindings.len() + 1); - hash_statements.push(quote! {::jj_lib::content_hash::ContentHash::hash(&#ix, state);}); + hash_statements.push(quote! {::jj_core::content_hash::ContentHash::hash(&#ix, state);}); for (ty, b) in &typed_bindings { hash_statements.push(quote_spanned! {b.span() => - <#ty as ::jj_lib::content_hash::ContentHash>::hash(#b, state); + <#ty as ::jj_core::content_hash::ContentHash>::hash(#b, state); }); } diff --git a/lib/core/proc-macros/src/lib.rs b/lib/core/proc-macros/src/lib.rs new file mode 100644 index 00000000000..1cb17071a93 --- /dev/null +++ b/lib/core/proc-macros/src/lib.rs @@ -0,0 +1,38 @@ +mod content_hash; + +extern crate proc_macro; + +use quote::quote; +use syn::DeriveInput; +use syn::parse_macro_input; + +/// Derive macro generating an impl of the trait `ContentHash`. +/// +/// Derives the `ContentHash` trait for a struct by calling `ContentHash::hash` +/// on each of the struct members in the order that they're declared. All +/// members of the struct must implement the `ContentHash` trait. +#[proc_macro_derive(ContentHash)] +pub fn derive_content_hash(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + // The name of the struct. + let name = &input.ident; + + // Generate an expression to hash each of the fields in the struct. + let hash_impl = content_hash::generate_hash_impl(&input.data); + + // Handle structs and enums with generics. + let generics = content_hash::add_trait_bounds(input.generics); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + let expanded = quote! { + #[automatically_derived] + impl #impl_generics ::jj_core::content_hash::ContentHash for #name #ty_generics + #where_clause { + fn hash(&self, state: &mut impl ::jj_core::content_hash::DigestUpdate) { + #hash_impl + } + } + }; + expanded.into() +} diff --git a/lib/core/src/content_hash.rs b/lib/core/src/content_hash.rs new file mode 100644 index 00000000000..92a67464e9d --- /dev/null +++ b/lib/core/src/content_hash.rs @@ -0,0 +1,282 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Portable, stable hashing suitable for identifying values + +use blake2::Blake2b512; +// Re-export DigestUpdate so that the ContentHash proc macro can be used in +// external crates without directly depending on the digest crate. +pub use digest::Update as DigestUpdate; +use itertools::Itertools as _; +pub use jj_core_proc_macros::ContentHash; + +/// Portable, stable hashing suitable for identifying values +/// +/// Variable-length sequences should hash a 64-bit little-endian representation +/// of their length, then their elements in order. Unordered containers should +/// order their elements according to their `Ord` implementation. Enums should +/// hash a 32-bit little-endian encoding of the ordinal number of the enum +/// variant, then the variant's fields in lexical order. +/// +/// Structs can implement `ContentHash` by using `#[derive(ContentHash)]`. +pub trait ContentHash { + /// Update the hasher state with this object's content + fn hash(&self, state: &mut impl DigestUpdate); +} + +/// The 512-bit BLAKE2b content hash +pub fn blake2b_hash(x: &(impl ContentHash + ?Sized)) -> digest::Output { + use digest::Digest as _; + let mut hasher = Blake2b512::default(); + x.hash(&mut hasher); + hasher.finalize() +} + +impl ContentHash for () { + fn hash(&self, _: &mut impl DigestUpdate) {} +} + +macro_rules! tuple_impls { + ($( ( $($n:tt $T:ident),+ ) )+) => { + $( + impl<$($T: ContentHash,)+> ContentHash for ($($T,)+) { + fn hash(&self, state: &mut impl DigestUpdate) { + $(self.$n.hash(state);)+ + } + } + )+ + } +} + +tuple_impls! { + (0 T0) + (0 T0, 1 T1) + (0 T0, 1 T1, 2 T2) + (0 T0, 1 T1, 2 T2, 3 T3) +} + +impl ContentHash for bool { + fn hash(&self, state: &mut impl DigestUpdate) { + u8::from(*self).hash(state); + } +} + +impl ContentHash for u8 { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&[*self]); + } +} + +impl ContentHash for u32 { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&self.to_le_bytes()); + } +} + +impl ContentHash for i32 { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&self.to_le_bytes()); + } +} + +impl ContentHash for u64 { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&self.to_le_bytes()); + } +} + +impl ContentHash for i64 { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&self.to_le_bytes()); + } +} + +// TODO: Specialize for [u8] once specialization exists +impl ContentHash for [T] { + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&(self.len() as u64).to_le_bytes()); + for x in self { + x.hash(state); + } + } +} + +impl ContentHash for Vec { + fn hash(&self, state: &mut impl DigestUpdate) { + self.as_slice().hash(state); + } +} + +impl ContentHash for str { + fn hash(&self, state: &mut impl DigestUpdate) { + self.as_bytes().hash(state); + } +} + +impl ContentHash for String { + fn hash(&self, state: &mut impl DigestUpdate) { + self.as_str().hash(state); + } +} + +impl ContentHash for Option { + fn hash(&self, state: &mut impl DigestUpdate) { + match self { + None => state.update(&0u32.to_le_bytes()), + Some(x) => { + state.update(&1u32.to_le_bytes()); + x.hash(state); + } + } + } +} + +impl ContentHash for std::collections::HashMap +where + K: ContentHash + Ord, + V: ContentHash, +{ + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&(self.len() as u64).to_le_bytes()); + let mut kv = self.iter().collect_vec(); + kv.sort_unstable_by_key(|&(k, _)| k); + for (k, v) in kv { + k.hash(state); + v.hash(state); + } + } +} + +impl ContentHash for std::collections::HashSet +where + K: ContentHash + Ord, +{ + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&(self.len() as u64).to_le_bytes()); + for k in self.iter().sorted() { + k.hash(state); + } + } +} + +impl ContentHash for std::collections::BTreeMap +where + K: ContentHash, + V: ContentHash, +{ + fn hash(&self, state: &mut impl DigestUpdate) { + state.update(&(self.len() as u64).to_le_bytes()); + for (k, v) in self { + k.hash(state); + v.hash(state); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::collections::HashMap; + + use super::*; + + #[test] + fn test_string_sanity() { + let a = "a".to_string(); + let b = "b".to_string(); + assert_eq!(hash(&a), hash(&a.clone())); + assert_ne!(hash(&a), hash(&b)); + assert_ne!(hash(&"a".to_string()), hash(&"a\0".to_string())); + } + + #[test] + fn test_hash_map_key_value_distinction() { + let a = [("ab".to_string(), "cd".to_string())] + .into_iter() + .collect::>(); + let b = [("a".to_string(), "bcd".to_string())] + .into_iter() + .collect::>(); + + assert_ne!(hash(&a), hash(&b)); + } + + #[test] + fn test_btree_map_key_value_distinction() { + let a = [("ab".to_string(), "cd".to_string())] + .into_iter() + .collect::>(); + let b = [("a".to_string(), "bcd".to_string())] + .into_iter() + .collect::>(); + + assert_ne!(hash(&a), hash(&b)); + } + + #[test] + fn test_tuple_sanity() { + #[derive(ContentHash)] + struct T1(i32); + #[derive(ContentHash)] + struct T2(i32, i32); + #[derive(ContentHash)] + struct T3(i32, i32, i32); + #[derive(ContentHash)] + struct T4(i32, i32, i32, i32); + assert_eq!(hash(&T1(0)), hash(&(0,))); + assert_eq!(hash(&T2(0, 1)), hash(&(0, 1))); + assert_eq!(hash(&T3(0, 1, 2)), hash(&(0, 1, 2))); + assert_eq!(hash(&T4(0, 1, 2, 3)), hash(&(0, 1, 2, 3))); + } + + #[test] + fn test_struct_sanity() { + #[derive(ContentHash)] + struct Foo { + x: i32, + } + assert_ne!(hash(&Foo { x: 42 }), hash(&Foo { x: 12 })); + } + + #[test] + fn test_option_sanity() { + assert_ne!(hash(&Some(42)), hash(&42)); + assert_ne!(hash(&None::), hash(&42i32)); + } + + #[test] + fn test_slice_sanity() { + assert_ne!(hash(&[42i32][..]), hash(&[12i32][..])); + assert_ne!(hash(&([] as [i32; 0])[..]), hash(&[42i32][..])); + assert_ne!(hash(&([] as [i32; 0])[..]), hash(&())); + assert_ne!(hash(&42i32), hash(&[42i32][..])); + } + + // Test that the derived version of `ContentHash` matches the that's + // manually implemented for `std::Option`. + #[test] + fn derive_for_enum() { + #[derive(ContentHash)] + enum MyOption { + None, + Some(T), + } + assert_eq!(hash(&Option::::None), hash(&MyOption::::None)); + assert_eq!(hash(&Some(1)), hash(&MyOption::Some(1))); + } + + fn hash(x: &(impl ContentHash + ?Sized)) -> digest::Output { + blake2b_hash(x) + } +} diff --git a/lib/core/src/file_util.rs b/lib/core/src/file_util.rs new file mode 100644 index 00000000000..7def07eb565 --- /dev/null +++ b/lib/core/src/file_util.rs @@ -0,0 +1,688 @@ +// Copyright 2021 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![expect(missing_docs)] + +use std::borrow::Cow; +use std::ffi::OsString; +use std::fs; +use std::fs::File; +use std::io; +use std::io::ErrorKind; +use std::io::Write; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use futures::AsyncRead; +use futures::AsyncReadExt as _; +use tempfile::NamedTempFile; +use tempfile::PersistError; +use thiserror::Error; + +#[cfg(unix)] +pub use self::platform::check_executable_bit_support; +pub use self::platform::check_symlink_support; +pub use self::platform::symlink_dir; +pub use self::platform::symlink_file; + +#[derive(Debug, Error)] +#[error("Cannot access {path}")] +pub struct PathError { + pub path: PathBuf, + pub source: io::Error, +} + +pub trait IoResultExt { + fn context(self, path: impl AsRef) -> Result; +} + +impl IoResultExt for io::Result { + fn context(self, path: impl AsRef) -> Result { + self.map_err(|error| PathError { + path: path.as_ref().to_path_buf(), + source: error, + }) + } +} + +/// Creates a directory or does nothing if the directory already exists. +/// +/// Returns the underlying error if the directory can't be created. +/// The function will also fail if intermediate directories on the path do not +/// already exist. +pub fn create_or_reuse_dir(dirname: &Path) -> io::Result<()> { + match fs::create_dir(dirname) { + Ok(()) => Ok(()), + Err(_) if dirname.is_dir() => Ok(()), + Err(e) => Err(e), + } +} + +/// Removes all files in the directory, but not the directory itself. +/// +/// The directory must exist, and there should be no sub directories. +pub fn remove_dir_contents(dirname: &Path) -> Result<(), PathError> { + for entry in dirname.read_dir().context(dirname)? { + let entry = entry.context(dirname)?; + let path = entry.path(); + fs::remove_file(&path).context(&path)?; + } + Ok(()) +} + +/// Checks if path points at an empty directory. +pub fn is_empty_dir(path: &Path) -> Result { + match path.read_dir() { + Ok(mut entries) => Ok(entries.next().is_none()), + Err(error) => match error.kind() { + ErrorKind::NotADirectory => Ok(false), + ErrorKind::NotFound => Ok(false), + _ => Err(error).context(path)?, + }, + } +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct BadPathEncoding(platform::BadOsStrEncoding); + +/// Constructs [`Path`] from `bytes` in platform-specific manner. +/// +/// On Unix, this function never fails because paths are just bytes. On Windows, +/// this may return error if the input wasn't well-formed UTF-8. +pub fn path_from_bytes(bytes: &[u8]) -> Result<&Path, BadPathEncoding> { + let s = platform::os_str_from_bytes(bytes).map_err(BadPathEncoding)?; + Ok(Path::new(s)) +} + +/// Converts `path` to bytes in platform-specific manner. +/// +/// On Unix, this function never fails because paths are just bytes. On Windows, +/// this may return error if the input wasn't well-formed UTF-8. +/// +/// The returned byte sequence can be considered a superset of ASCII (such as +/// UTF-8 bytes.) +pub fn path_to_bytes(path: &Path) -> Result<&[u8], BadPathEncoding> { + platform::os_str_to_bytes(path.as_ref()).map_err(BadPathEncoding) +} + +/// Expands "~/" to the user's home directory. +pub fn expand_home_path(path_str: &str) -> PathBuf { + if let Some(remainder) = path_str.strip_prefix("~/") + && let Ok(home_dir) = etcetera::home_dir() + { + return home_dir.join(remainder); + } + PathBuf::from(path_str) +} + +/// Turns the given `to` path into relative path starting from the `from` path. +/// +/// Both `from` and `to` paths are supposed to be absolute and normalized in the +/// same manner. If `from` and `to` share no common prefix, the returned path is +/// unchanged. This also means `relative_path(abs, rel)` will return `rel`. +pub fn relative_path(from: &Path, to: &Path) -> PathBuf { + let Some((from_suffix, to_suffix)) = strip_common_path_prefix(from, to) else { + // No common prefix found. Return the original path. + return to.to_owned(); + }; + let depth = from_suffix.components().count(); + let mut relative = PathBuf::with_capacity(2 * depth + 1 + to_suffix.as_os_str().len()); + for _ in 0..depth { + relative.push(Component::ParentDir); + } + if !to_suffix.as_os_str().is_empty() { + relative.push(to_suffix); + } else if depth == 0 { + relative.push(Component::CurDir); + } + relative +} + +fn strip_common_path_prefix<'a, 'b>( + path1: &'a Path, + path2: &'b Path, +) -> Option<(&'a Path, &'b Path)> { + let mut components1 = path1.components(); + let mut components2 = path2.components(); + let mut suffix_paths = None; + while let (Some(c1), Some(c2)) = (components1.next(), components2.next()) { + if c1 != c2 { + break; + } + suffix_paths = Some((components1.as_path(), components2.as_path())); + } + suffix_paths +} + +/// Consumes as much `..` and `.` as possible without considering symlinks. +pub fn normalize_path(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir + if matches!(result.components().next_back(), Some(Component::Normal(_))) => + { + // Do not pop ".." + let popped = result.pop(); + assert!(popped); + } + _ => { + result.push(c); + } + } + } + + if result.as_os_str().is_empty() { + ".".into() + } else { + result + } +} + +/// Converts the given `path` to Unix-like path separated by "/". +/// +/// The returned path might not work on Windows if it was canonicalized. On +/// Unix, this function is noop. +pub fn slash_path(path: &Path) -> Cow<'_, Path> { + if cfg!(windows) { + Cow::Owned(to_slash_separated(path).into()) + } else { + Cow::Borrowed(path) + } +} + +fn to_slash_separated(path: &Path) -> OsString { + let mut buf = OsString::with_capacity(path.as_os_str().len()); + let mut components = path.components(); + match components.next() { + Some(c) => buf.push(c), + None => return buf, + } + for c in components { + buf.push("/"); + buf.push(c); + } + buf +} + +/// Persists the temporary file after synchronizing the content. +/// +/// After system crash, the persisted file should have a valid content if +/// existed. However, the persisted file name (or directory entry) could be +/// lost. It's up to caller to synchronize the directory entries. +/// +/// See also for the behavior on Linux. +pub fn persist_temp_file>( + temp_file: NamedTempFile, + new_path: P, +) -> io::Result { + // Ensure persisted file content is flushed to disk. + temp_file.as_file().sync_data()?; + temp_file + .persist(new_path) + .map_err(|PersistError { error, file: _ }| error) +} + +/// Like [`persist_temp_file()`], but doesn't try to overwrite the existing +/// target on Windows. +pub fn persist_content_addressed_temp_file>( + temp_file: NamedTempFile, + new_path: P, +) -> io::Result { + // Ensure new file content is flushed to disk, so the old file content + // wouldn't be lost if existed at the same location. + temp_file.as_file().sync_data()?; + if cfg!(windows) { + // On Windows, overwriting file can fail if the file is opened without + // FILE_SHARE_DELETE for example. We don't need to take a risk if the + // file already exists. + match temp_file.persist_noclobber(&new_path) { + Ok(file) => Ok(file), + Err(PersistError { error, file: _ }) => { + if let Ok(existing_file) = File::open(new_path) { + // TODO: Update mtime to help GC keep this file + Ok(existing_file) + } else { + Err(error) + } + } + } + } else { + // On Unix, rename() is atomic and should succeed even if the + // destination file exists. Checking if the target exists might involve + // non-atomic operation, so don't use persist_noclobber(). + temp_file + .persist(new_path) + .map_err(|PersistError { error, file: _ }| error) + } +} + +/// Opaque value that can be tested to know whether file or directory paths +/// point to the same filesystem entity. +/// +/// The primary use case is to detect file name aliases on case-insensitive +/// filesystem. On Unix, device and inode numbers are compared. +#[derive(Debug, Eq, Hash, PartialEq)] +pub struct FileIdentity(platform::FileIdentity); + +impl FileIdentity { + /// Queries file identity without following symlinks. + /// BUG: On Windows, symbolic links would be followed. + pub fn from_symlink_path(path: impl AsRef) -> io::Result { + platform::file_identity_from_symlink_path(path.as_ref()).map(Self) + } + + /// Queries file identity of the given `file`. + // TODO: do not consume file object + pub fn from_file(file: File) -> io::Result { + platform::file_identity_from_file(file).map(Self) + } +} + +/// Reads from an async source and writes to a sync destination. Does not spawn +/// a task, so writes will block. +pub async fn copy_async_to_sync( + reader: R, + writer: &mut W, +) -> io::Result { + let mut buf = vec![0; 16 << 10]; + let mut total_written_bytes = 0; + + let mut reader = std::pin::pin!(reader); + loop { + let written_bytes = reader.read(&mut buf).await?; + if written_bytes == 0 { + return Ok(total_written_bytes); + } + writer.write_all(&buf[0..written_bytes])?; + total_written_bytes += written_bytes; + } +} + +#[cfg(unix)] +mod platform { + use std::convert::Infallible; + use std::ffi::OsStr; + use std::fs; + use std::fs::File; + use std::io; + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::MetadataExt as _; + use std::os::unix::fs::PermissionsExt; + use std::os::unix::fs::symlink; + use std::path::Path; + + pub type BadOsStrEncoding = Infallible; + + pub fn os_str_from_bytes(data: &[u8]) -> Result<&OsStr, BadOsStrEncoding> { + Ok(OsStr::from_bytes(data)) + } + + pub fn os_str_to_bytes(data: &OsStr) -> Result<&[u8], BadOsStrEncoding> { + Ok(data.as_bytes()) + } + + /// Whether changing executable bits is permitted on the filesystem of this + /// directory, and whether attempting to flip one has an observable effect. + pub fn check_executable_bit_support(path: impl AsRef) -> io::Result { + // Get current permissions and try to flip just the user's executable bit. + let temp_file = tempfile::tempfile_in(path)?; + let old_mode = temp_file.metadata()?.permissions().mode(); + let new_mode = old_mode ^ 0o100; + let result = temp_file.set_permissions(PermissionsExt::from_mode(new_mode)); + match result { + // If permission was denied, we do not have executable bit support. + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => Ok(false), + Err(err) => Err(err), + Ok(()) => { + // Verify that the permission change was not silently ignored. + let mode = temp_file.metadata()?.permissions().mode(); + Ok(mode == new_mode) + } + } + } + + /// Symlinks are always available on Unix. + pub fn check_symlink_support() -> io::Result { + Ok(true) + } + + /// Creates a new symlink `link` pointing to the `original` path. + /// + /// On Unix, the `original` path doesn't have to be a directory. + pub fn symlink_dir, Q: AsRef>(original: P, link: Q) -> io::Result<()> { + symlink(original, link) + } + + /// Creates a new symlink `link` pointing to the `original` path. + /// + /// On Unix, the `original` path doesn't have to be a file. + pub fn symlink_file, Q: AsRef>(original: P, link: Q) -> io::Result<()> { + symlink(original, link) + } + + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + pub struct FileIdentity { + // https://github.com/BurntSushi/same-file/blob/1.0.6/src/unix.rs#L30 + dev: u64, + ino: u64, + } + + impl FileIdentity { + fn from_metadata(metadata: fs::Metadata) -> Self { + Self { + dev: metadata.dev(), + ino: metadata.ino(), + } + } + } + + pub fn file_identity_from_symlink_path(path: &Path) -> io::Result { + path.symlink_metadata().map(FileIdentity::from_metadata) + } + + pub fn file_identity_from_file(file: File) -> io::Result { + file.metadata().map(FileIdentity::from_metadata) + } +} + +#[cfg(windows)] +mod platform { + use std::fs::File; + use std::io; + pub use std::os::windows::fs::symlink_dir; + pub use std::os::windows::fs::symlink_file; + use std::path::Path; + + use winreg::RegKey; + use winreg::enums::HKEY_LOCAL_MACHINE; + + pub use super::fallback::BadOsStrEncoding; + pub use super::fallback::os_str_from_bytes; + pub use super::fallback::os_str_to_bytes; + + /// Symlinks may or may not be enabled on Windows. They require the + /// Developer Mode setting, which is stored in the registry key below. + /// + /// Note: If developer mode is not enabled, the error code of symlink + /// creation will be 1314, `ERROR_PRIVILEGE_NOT_HELD`. + pub fn check_symlink_support() -> io::Result { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let sideloading = + hklm.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModelUnlock")?; + let developer_mode: u32 = sideloading.get_value("AllowDevelopmentWithoutDevLicense")?; + Ok(developer_mode == 1) + } + + pub type FileIdentity = same_file::Handle; + + // FIXME: This shouldn't follow symlinks when querying file identity. + // Perhaps, we need to open file with FILE_FLAG_BACKUP_SEMANTICS and + // FILE_FLAG_OPEN_REPARSE_POINT, then pass it to from_file(). Alternatively, + // maybe we can use symlink_metadata(), volume_serial_number(), and + // file_index() when they get stabilized. See the same-file crate and std + // lstat() implementation. https://github.com/rust-lang/rust/issues/63010 + pub fn file_identity_from_symlink_path(path: &Path) -> io::Result { + same_file::Handle::from_path(path) + } + + pub fn file_identity_from_file(file: File) -> io::Result { + same_file::Handle::from_file(file) + } +} + +#[cfg_attr(unix, expect(dead_code))] +mod fallback { + use std::ffi::OsStr; + + use thiserror::Error; + + // Define error per platform so we can explicitly say UTF-8 is expected. + #[derive(Debug, Error)] + #[error("Invalid UTF-8 sequence")] + pub struct BadOsStrEncoding; + + pub fn os_str_from_bytes(data: &[u8]) -> Result<&OsStr, BadOsStrEncoding> { + Ok(str::from_utf8(data).map_err(|_| BadOsStrEncoding)?.as_ref()) + } + + pub fn os_str_to_bytes(data: &OsStr) -> Result<&[u8], BadOsStrEncoding> { + Ok(data.to_str().ok_or(BadOsStrEncoding)?.as_ref()) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use futures::io::Cursor; + use itertools::Itertools as _; + use pollster::FutureExt as _; + use test_case::test_case; + + use super::*; + use crate::tests::TestResult; + use crate::tests::new_temp_dir; + + #[test] + #[cfg(unix)] + fn exec_bit_support_in_temp_dir() -> TestResult { + // Temporary directories on Unix should always have executable support. + // Note that it would be problematic to test in a non-temp directory, as + // a developer's filesystem may or may not have executable bit support. + let dir = new_temp_dir(); + let supported = check_executable_bit_support(dir.path())?; + assert!(supported); + Ok(()) + } + + #[test] + fn test_path_bytes_roundtrip() -> TestResult { + let bytes = b"ascii"; + let path = path_from_bytes(bytes)?; + assert_eq!(path_to_bytes(path)?, bytes); + + let bytes = b"utf-8.\xc3\xa0"; + let path = path_from_bytes(bytes)?; + assert_eq!(path_to_bytes(path)?, bytes); + + let bytes = b"latin1.\xe0"; + if cfg!(unix) { + let path = path_from_bytes(bytes)?; + assert_eq!(path_to_bytes(path)?, bytes); + } else { + assert!(path_from_bytes(bytes).is_err()); + } + Ok(()) + } + + #[test] + fn normalize_too_many_dot_dot() { + assert_eq!(normalize_path(Path::new("foo/..")), Path::new(".")); + assert_eq!(normalize_path(Path::new("foo/../..")), Path::new("..")); + assert_eq!( + normalize_path(Path::new("foo/../../..")), + Path::new("../..") + ); + assert_eq!( + normalize_path(Path::new("foo/../../../bar/baz/..")), + Path::new("../../bar") + ); + } + + #[test] + fn test_slash_path() { + assert_eq!(slash_path(Path::new("")), Path::new("")); + assert_eq!(slash_path(Path::new("foo")), Path::new("foo")); + assert_eq!(slash_path(Path::new("foo/bar")), Path::new("foo/bar")); + assert_eq!(slash_path(Path::new("foo/bar/..")), Path::new("foo/bar/..")); + assert_eq!( + slash_path(Path::new(r"foo\bar")), + if cfg!(windows) { + Path::new("foo/bar") + } else { + Path::new(r"foo\bar") + } + ); + assert_eq!( + slash_path(Path::new(r"..\foo\bar")), + if cfg!(windows) { + Path::new("../foo/bar") + } else { + Path::new(r"..\foo\bar") + } + ); + } + + #[test] + fn test_persist_no_existing_file() -> TestResult { + let temp_dir = new_temp_dir(); + let target = temp_dir.path().join("file"); + let mut temp_file = NamedTempFile::new_in(&temp_dir)?; + temp_file.write_all(b"contents")?; + assert!(persist_content_addressed_temp_file(temp_file, target).is_ok()); + Ok(()) + } + + #[test_case(false ; "existing file open")] + #[test_case(true ; "existing file closed")] + fn test_persist_target_exists(existing_file_closed: bool) -> TestResult { + let temp_dir = new_temp_dir(); + let target = temp_dir.path().join("file"); + let mut temp_file = NamedTempFile::new_in(&temp_dir)?; + temp_file.write_all(b"contents")?; + + let mut file = File::create(&target)?; + file.write_all(b"contents")?; + if existing_file_closed { + drop(file); + } + + assert!(persist_content_addressed_temp_file(temp_file, &target).is_ok()); + Ok(()) + } + + #[test] + fn test_file_identity_hard_link() -> TestResult { + let temp_dir = new_temp_dir(); + let file_path = temp_dir.path().join("file"); + let other_file_path = temp_dir.path().join("other_file"); + let link_path = temp_dir.path().join("link"); + fs::write(&file_path, "")?; + fs::write(&other_file_path, "")?; + fs::hard_link(&file_path, &link_path)?; + assert_eq!( + FileIdentity::from_symlink_path(&file_path)?, + FileIdentity::from_symlink_path(&link_path)? + ); + assert_ne!( + FileIdentity::from_symlink_path(&other_file_path)?, + FileIdentity::from_symlink_path(&link_path)? + ); + assert_eq!( + FileIdentity::from_symlink_path(&file_path)?, + FileIdentity::from_file(File::open(&link_path)?)? + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn test_file_identity_unix_symlink_dir() -> TestResult { + let temp_dir = new_temp_dir(); + let dir_path = temp_dir.path().join("dir"); + let symlink_path = temp_dir.path().join("symlink"); + fs::create_dir(&dir_path)?; + std::os::unix::fs::symlink("dir", &symlink_path)?; + // symlink should be identical to itself + assert_eq!( + FileIdentity::from_symlink_path(&symlink_path)?, + FileIdentity::from_symlink_path(&symlink_path)? + ); + // symlink should be different from the target directory + assert_ne!( + FileIdentity::from_symlink_path(&dir_path)?, + FileIdentity::from_symlink_path(&symlink_path)? + ); + // File::open() follows symlinks + assert_eq!( + FileIdentity::from_symlink_path(&dir_path)?, + FileIdentity::from_file(File::open(&symlink_path)?)? + ); + assert_ne!( + FileIdentity::from_symlink_path(&symlink_path)?, + FileIdentity::from_file(File::open(&symlink_path)?)? + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn test_file_identity_unix_symlink_loop() -> TestResult { + let temp_dir = new_temp_dir(); + let lower_file_path = temp_dir.path().join("file"); + let upper_file_path = temp_dir.path().join("FILE"); + let lower_symlink_path = temp_dir.path().join("symlink"); + let upper_symlink_path = temp_dir.path().join("SYMLINK"); + fs::write(&lower_file_path, "")?; + std::os::unix::fs::symlink("symlink", &lower_symlink_path)?; + let is_icase_fs = upper_file_path.try_exists()?; + // symlink should be identical to itself + assert_eq!( + FileIdentity::from_symlink_path(&lower_symlink_path)?, + FileIdentity::from_symlink_path(&lower_symlink_path)? + ); + assert_ne!( + FileIdentity::from_symlink_path(&lower_symlink_path)?, + FileIdentity::from_symlink_path(&lower_file_path)? + ); + if is_icase_fs { + assert_eq!( + FileIdentity::from_symlink_path(&lower_symlink_path)?, + FileIdentity::from_symlink_path(&upper_symlink_path)? + ); + } else { + assert!(FileIdentity::from_symlink_path(&upper_symlink_path).is_err()); + } + Ok(()) + } + + #[test] + fn test_copy_async_to_sync_small() -> TestResult { + let input = b"hello"; + let mut output = vec![]; + + let result = copy_async_to_sync(Cursor::new(&input), &mut output).block_on(); + assert!(result.is_ok()); + assert_eq!(result?, 5); + assert_eq!(output, input); + Ok(()) + } + + #[test] + fn test_copy_async_to_sync_large() -> TestResult { + // More than 1 buffer worth of data + let input = (0..100u8).cycle().take(40000).collect_vec(); + let mut output = vec![]; + + let result = copy_async_to_sync(Cursor::new(&input), &mut output).block_on(); + assert!(result.is_ok()); + assert_eq!(result?, 40000); + assert_eq!(output, input); + Ok(()) + } +} diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index 02eb60d1a21..603dc546dba 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -18,3 +18,33 @@ #![warn(missing_docs)] #![forbid(unsafe_code)] #![deny(unused_must_use)] + +// Needed so that proc macros can be used inside jj_lib and by external crates +// that depend on it. +// See: +// - https://github.com/rust-lang/rust/issues/54647#issuecomment-432015102 +// - https://github.com/rust-lang/rust/issues/54363 +extern crate self as jj_core; + +#[macro_use] +pub mod content_hash; + +pub mod file_util; +pub mod repo_path; + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + // Copied from `testutils::TestResult` to remove dependency cycle. + pub type TestResult = eyre::Result; + + /// Unlike `testutils::new_temp_dir()`, this function doesn't set up + /// hermetic Git environment. + pub fn new_temp_dir() -> TempDir { + tempfile::Builder::new() + .prefix("jj-test-") + .tempdir() + .unwrap() + } +} diff --git a/lib/core/src/repo_path.rs b/lib/core/src/repo_path.rs new file mode 100644 index 00000000000..8d6c62e90b4 --- /dev/null +++ b/lib/core/src/repo_path.rs @@ -0,0 +1,1170 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(missing_docs)] + +use std::borrow::Borrow; +use std::cmp::Ordering; +use std::fmt; +use std::fmt::Debug; +use std::fmt::Formatter; +use std::iter::FusedIterator; +use std::ops::Deref; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use ref_cast::RefCastCustom; +use ref_cast::ref_cast_custom; +use thiserror::Error; + +use crate::content_hash::ContentHash; +use crate::file_util; + +/// Owned `RepoPath` component. +#[derive(ContentHash, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RepoPathComponentBuf { + // Don't add more fields. Eq, Hash, and Ord must be compatible with the + // borrowed RepoPathComponent type. + value: String, +} + +impl RepoPathComponentBuf { + /// Wraps `value` as `RepoPathComponentBuf`. + /// + /// Returns an error if the input `value` is empty or contains path + /// separator. + pub fn new(value: impl Into) -> Result { + let value: String = value.into(); + if is_valid_repo_path_component_str(&value) { + Ok(Self { value }) + } else { + Err(InvalidNewRepoPathError { value }) + } + } +} + +/// Borrowed `RepoPath` component. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom)] +#[repr(transparent)] +pub struct RepoPathComponent { + value: str, +} + +impl RepoPathComponent { + /// Wraps `value` as `RepoPathComponent`. + /// + /// Returns an error if the input `value` is empty or contains path + /// separator. + pub fn new(value: &str) -> Result<&Self, InvalidNewRepoPathError> { + if is_valid_repo_path_component_str(value) { + Ok(Self::new_unchecked(value)) + } else { + Err(InvalidNewRepoPathError { + value: value.to_string(), + }) + } + } + + #[ref_cast_custom] + const fn new_unchecked(value: &str) -> &Self; + + /// Returns the underlying string representation. + pub fn as_internal_str(&self) -> &str { + &self.value + } + + /// Returns a normal filesystem entry name if this path component is valid + /// as a file/directory name. + pub fn to_fs_name(&self) -> Result<&str, InvalidRepoPathComponentError> { + let mut components = Path::new(&self.value).components().fuse(); + match (components.next(), components.next()) { + // Trailing "." can be normalized by Path::components(), so compare + // component name. e.g. "foo\." (on Windows) should be rejected. + (Some(Component::Normal(name)), None) if name == &self.value => Ok(&self.value), + // e.g. ".", "..", "foo\bar" (on Windows) + _ => Err(InvalidRepoPathComponentError { + component: self.value.into(), + }), + } + } +} + +impl Debug for RepoPathComponent { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", &self.value) + } +} + +impl Debug for RepoPathComponentBuf { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + ::fmt(self, f) + } +} + +impl AsRef for RepoPathComponent { + fn as_ref(&self) -> &Self { + self + } +} + +impl AsRef for RepoPathComponentBuf { + fn as_ref(&self) -> &RepoPathComponent { + self + } +} + +impl Borrow for RepoPathComponentBuf { + fn borrow(&self) -> &RepoPathComponent { + self + } +} + +impl Deref for RepoPathComponentBuf { + type Target = RepoPathComponent; + + fn deref(&self) -> &Self::Target { + RepoPathComponent::new_unchecked(&self.value) + } +} + +impl ToOwned for RepoPathComponent { + type Owned = RepoPathComponentBuf; + + fn to_owned(&self) -> Self::Owned { + let value = self.value.to_owned(); + RepoPathComponentBuf { value } + } + + fn clone_into(&self, target: &mut Self::Owned) { + self.value.clone_into(&mut target.value); + } +} + +/// Iterator over `RepoPath` components. +#[derive(Clone, Debug)] +pub struct RepoPathComponentsIter<'a> { + value: &'a str, +} + +impl<'a> RepoPathComponentsIter<'a> { + /// Returns the remaining part as repository path. + pub fn as_path(&self) -> &'a RepoPath { + RepoPath::from_internal_string_unchecked(self.value) + } +} + +impl<'a> Iterator for RepoPathComponentsIter<'a> { + type Item = &'a RepoPathComponent; + + fn next(&mut self) -> Option { + if self.value.is_empty() { + return None; + } + let (name, remainder) = self + .value + .split_once('/') + .unwrap_or_else(|| (self.value, &self.value[self.value.len()..])); + self.value = remainder; + Some(RepoPathComponent::new_unchecked(name)) + } +} + +impl DoubleEndedIterator for RepoPathComponentsIter<'_> { + fn next_back(&mut self) -> Option { + if self.value.is_empty() { + return None; + } + let (remainder, name) = self + .value + .rsplit_once('/') + .unwrap_or_else(|| (&self.value[..0], self.value)); + self.value = remainder; + Some(RepoPathComponent::new_unchecked(name)) + } +} + +impl FusedIterator for RepoPathComponentsIter<'_> {} + +/// Owned repository path. +#[derive(ContentHash, Clone, Eq, Hash, PartialEq, serde::Serialize)] +#[serde(transparent)] +pub struct RepoPathBuf { + // Don't add more fields. Eq, Hash, and Ord must be compatible with the + // borrowed RepoPath type. + value: String, +} + +/// Borrowed repository path. +#[derive(ContentHash, Eq, Hash, PartialEq, RefCastCustom, serde::Serialize)] +#[repr(transparent)] +#[serde(transparent)] +pub struct RepoPath { + value: str, +} + +impl Debug for RepoPath { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", &self.value) + } +} + +impl Debug for RepoPathBuf { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + ::fmt(self, f) + } +} + +/// The `value` is not a valid repo path because it contains empty path +/// component. For example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all +/// invalid. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error(r#"Invalid repo path input "{value}""#)] +pub struct InvalidNewRepoPathError { + value: String, +} + +impl RepoPathBuf { + /// Creates owned repository path pointing to the root. + pub const fn root() -> Self { + Self { + value: String::new(), + } + } + + /// Creates `RepoPathBuf` from valid string representation. + pub fn from_internal_string(value: impl Into) -> Result { + let value: String = value.into(); + if is_valid_repo_path_str(&value) { + Ok(Self { value }) + } else { + Err(InvalidNewRepoPathError { value }) + } + } + + /// Converts repo-relative `Path` to `RepoPathBuf`. + /// + /// The input path should not contain redundant `.` or `..`. + pub fn from_relative_path( + relative_path: impl AsRef, + ) -> Result { + let relative_path = relative_path.as_ref(); + if relative_path == Path::new(".") { + return Ok(Self::root()); + } + + let mut components = relative_path + .components() + .map(|c| match c { + Component::Normal(name) => { + name.to_str() + .ok_or_else(|| RelativePathParseError::InvalidUtf8 { + path: relative_path.into(), + }) + } + _ => Err(RelativePathParseError::InvalidComponent { + component: c.as_os_str().to_string_lossy().into(), + path: relative_path.into(), + }), + }) + .fuse(); + let mut value = String::with_capacity(relative_path.as_os_str().len()); + if let Some(name) = components.next() { + value.push_str(name?); + } + for name in components { + value.push('/'); + value.push_str(name?); + } + Ok(Self { value }) + } + + /// Parses an `input` path into a `RepoPathBuf` relative to `base`. + /// + /// The `cwd` and `base` paths are supposed to be absolute and normalized in + /// the same manner. The `input` path may be either relative to `cwd` or + /// absolute. + pub fn parse_fs_path( + cwd: &Path, + base: &Path, + input: impl AsRef, + ) -> Result { + let input = input.as_ref(); + let abs_input_path = file_util::normalize_path(&cwd.join(input)); + let repo_relative_path = file_util::relative_path(base, &abs_input_path); + Self::from_relative_path(repo_relative_path).map_err(|source| FsPathParseError { + base: file_util::relative_path(cwd, base).into(), + input: input.into(), + source, + }) + } + + /// Consumes this and returns the underlying string representation. + pub fn into_internal_string(self) -> String { + self.value + } +} + +impl RepoPath { + /// Returns repository path pointing to the root. + pub const fn root() -> &'static Self { + Self::from_internal_string_unchecked("") + } + + /// Wraps valid string representation as `RepoPath`. + /// + /// Returns an error if the input `value` contains empty path component. For + /// example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all invalid. + pub fn from_internal_string(value: &str) -> Result<&Self, InvalidNewRepoPathError> { + if is_valid_repo_path_str(value) { + Ok(Self::from_internal_string_unchecked(value)) + } else { + Err(InvalidNewRepoPathError { + value: value.to_owned(), + }) + } + } + + #[ref_cast_custom] + const fn from_internal_string_unchecked(value: &str) -> &Self; + + /// The full string form used internally, not for presenting to users (where + /// we may want to use the platform's separator). This format includes a + /// trailing slash, unless this path represents the root directory. That + /// way it can be concatenated with a basename and produce a valid path. + pub fn to_internal_dir_string(&self) -> String { + if self.value.is_empty() { + String::new() + } else { + [&self.value, "/"].concat() + } + } + + /// The full string form used internally, not for presenting to users (where + /// we may want to use the platform's separator). + pub fn as_internal_file_string(&self) -> &str { + &self.value + } + + /// Converts repository path to filesystem path relative to the `base`. + /// + /// The returned path should never contain `..`, `C:` (on Windows), etc. + /// However, it may contain reserved working-copy directories such as `.jj`. + pub fn to_fs_path(&self, base: &Path) -> Result { + let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1); + result.push(base); + for c in self.components() { + result.push(c.to_fs_name().map_err(|err| err.with_path(self))?); + } + if result.as_os_str().is_empty() { + result.push("."); + } + Ok(result) + } + + /// Converts repository path to filesystem path relative to the `base`, + /// without checking invalid path components. + /// + /// The returned path may point outside of the `base` directory. Use this + /// function only for displaying or testing purposes. + pub fn to_fs_path_unchecked(&self, base: &Path) -> PathBuf { + let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1); + result.push(base); + result.extend(self.components().map(RepoPathComponent::as_internal_str)); + if result.as_os_str().is_empty() { + result.push("."); + } + result + } + + pub fn is_root(&self) -> bool { + self.value.is_empty() + } + + /// Returns true if the `base` is a prefix of this path. + pub fn starts_with(&self, base: &Self) -> bool { + self.strip_prefix(base).is_some() + } + + /// Returns the remaining path with the `base` path removed. + pub fn strip_prefix(&self, base: &Self) -> Option<&Self> { + if base.value.is_empty() { + Some(self) + } else { + let tail = self.value.strip_prefix(&base.value)?; + if tail.is_empty() { + Some(Self::from_internal_string_unchecked(tail)) + } else { + tail.strip_prefix('/') + .map(Self::from_internal_string_unchecked) + } + } + } + + /// Returns the parent path without the base name component. + pub fn parent(&self) -> Option<&Self> { + self.split().map(|(parent, _)| parent) + } + + /// Splits this into the parent path and base name component. + pub fn split(&self) -> Option<(&Self, &RepoPathComponent)> { + let mut components = self.components(); + let basename = components.next_back()?; + Some((components.as_path(), basename)) + } + + pub fn components(&self) -> RepoPathComponentsIter<'_> { + RepoPathComponentsIter { value: &self.value } + } + + pub fn ancestors(&self) -> impl Iterator { + std::iter::successors(Some(self), |path| path.parent()) + } + + pub fn join(&self, entry: &RepoPathComponent) -> RepoPathBuf { + let value = if self.value.is_empty() { + entry.as_internal_str().to_owned() + } else { + [&self.value, "/", entry.as_internal_str()].concat() + }; + RepoPathBuf { value } + } + + /// Splits this path at its common prefix with `other`. + /// + /// # Returns + /// + /// Returns the `(common_prefix, self_remainder)`. + /// + /// All paths will at least have `RepoPath::root()` as a common prefix, + /// therefore even if `self` and `other` have no matching parent component + /// this function will always return at least `(RepoPath::root(), self)`. + /// + /// + /// # Examples + /// + /// ``` + /// use jj_core::repo_path::RepoPath; + /// + /// let bing_path = RepoPath::from_internal_string("foo/bar/bing").unwrap(); + /// + /// let baz_path = RepoPath::from_internal_string("foo/bar/baz").unwrap(); + /// + /// let foo_bar_path = RepoPath::from_internal_string("foo/bar").unwrap(); + /// + /// assert_eq!( + /// bing_path.split_common_prefix(&baz_path), + /// (foo_bar_path, RepoPath::from_internal_string("bing").unwrap()) + /// ); + /// + /// let unrelated_path = RepoPath::from_internal_string("no/common/prefix").unwrap(); + /// assert_eq!( + /// baz_path.split_common_prefix(&unrelated_path), + /// (RepoPath::root(), baz_path) + /// ); + /// ``` + pub fn split_common_prefix(&self, other: &Self) -> (&Self, &Self) { + // Obtain the common prefix between these paths + let mut prefix_len = 0; + + let common_components = self + .components() + .zip(other.components()) + .take_while(|(prev_comp, this_comp)| prev_comp == this_comp); + + for (self_comp, _other_comp) in common_components { + if prefix_len > 0 { + // + 1 for all paths to take their separators into account. + // We skip the first one since there are ComponentCount - 1 separators in a + // path. + prefix_len += 1; + } + + prefix_len += self_comp.value.len(); + } + + if prefix_len == 0 { + // No common prefix except root + return (Self::root(), self); + } + + if prefix_len == self.value.len() { + return (self, Self::root()); + } + + let common_prefix = Self::from_internal_string_unchecked(&self.value[..prefix_len]); + let remainder = Self::from_internal_string_unchecked(&self.value[prefix_len + 1..]); + + (common_prefix, remainder) + } +} + +impl AsRef for RepoPath { + fn as_ref(&self) -> &Self { + self + } +} + +impl AsRef for RepoPathBuf { + fn as_ref(&self) -> &RepoPath { + self + } +} + +impl Borrow for RepoPathBuf { + fn borrow(&self) -> &RepoPath { + self + } +} + +impl Deref for RepoPathBuf { + type Target = RepoPath; + + fn deref(&self) -> &Self::Target { + RepoPath::from_internal_string_unchecked(&self.value) + } +} + +impl ToOwned for RepoPath { + type Owned = RepoPathBuf; + + fn to_owned(&self) -> Self::Owned { + let value = self.value.to_owned(); + RepoPathBuf { value } + } + + fn clone_into(&self, target: &mut Self::Owned) { + self.value.clone_into(&mut target.value); + } +} + +impl Ord for RepoPath { + fn cmp(&self, other: &Self) -> Ordering { + // If there were leading/trailing slash, components-based Ord would + // disagree with str-based Eq. + debug_assert!(is_valid_repo_path_str(&self.value)); + self.components().cmp(other.components()) + } +} + +impl Ord for RepoPathBuf { + fn cmp(&self, other: &Self) -> Ordering { + ::cmp(self, other) + } +} + +impl PartialOrd for RepoPath { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialOrd for RepoPathBuf { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl> Extend

for RepoPathBuf { + fn extend>(&mut self, iter: T) { + for component in iter { + if !self.value.is_empty() { + self.value.push('/'); + } + self.value.push_str(component.as_ref().as_internal_str()); + } + } +} + +/// `RepoPath` contained invalid file/directory component such as `..`. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error(r#"Invalid repository path "{}""#, path.as_internal_file_string())] +pub struct InvalidRepoPathError { + /// Path containing an error. + pub path: RepoPathBuf, + /// Source error. + pub source: InvalidRepoPathComponentError, +} + +/// `RepoPath` component was invalid. (e.g. `..`) +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error(r#"Invalid path component "{component}""#)] +pub struct InvalidRepoPathComponentError { + pub component: Box, +} + +impl InvalidRepoPathComponentError { + /// Attaches the `path` that caused the error. + pub fn with_path(self, path: &RepoPath) -> InvalidRepoPathError { + InvalidRepoPathError { + path: path.to_owned(), + source: self, + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum RelativePathParseError { + #[error(r#"Invalid component "{component}" in repo-relative path "{path}""#)] + InvalidComponent { + component: Box, + path: Box, + }, + #[error(r#"Not valid UTF-8 path "{path}""#)] + InvalidUtf8 { path: Box }, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error(r#"Path "{input}" is not in the repo "{base}""#)] +pub struct FsPathParseError { + /// Repository or workspace root path relative to the `cwd`. + pub base: Box, + /// Input path without normalization. + pub input: Box, + /// Source error. + pub source: RelativePathParseError, +} + +fn is_valid_repo_path_component_str(value: &str) -> bool { + !value.is_empty() && !value.contains('/') +} + +fn is_valid_repo_path_str(value: &str) -> bool { + !value.starts_with('/') && !value.ends_with('/') && !value.contains("//") +} + +#[cfg(test)] +mod tests { + use std::panic; + + use assert_matches::assert_matches; + use itertools::Itertools as _; + + use super::*; + use crate::tests::new_temp_dir; + + fn repo_path(value: &str) -> &RepoPath { + RepoPath::from_internal_string(value).unwrap() + } + + fn repo_path_component(value: &str) -> &RepoPathComponent { + RepoPathComponent::new(value).unwrap() + } + + #[test] + fn test_is_root() { + assert!(RepoPath::root().is_root()); + assert!(repo_path("").is_root()); + assert!(!repo_path("foo").is_root()); + } + + #[test] + fn test_from_internal_string() { + let repo_path_buf = |value: &str| RepoPathBuf::from_internal_string(value).unwrap(); + assert_eq!(repo_path_buf(""), RepoPathBuf::root()); + assert!(panic::catch_unwind(|| repo_path_buf("/")).is_err()); + assert!(panic::catch_unwind(|| repo_path_buf("/x")).is_err()); + assert!(panic::catch_unwind(|| repo_path_buf("x/")).is_err()); + assert!(panic::catch_unwind(|| repo_path_buf("x//y")).is_err()); + + assert_eq!(repo_path(""), RepoPath::root()); + assert!(panic::catch_unwind(|| repo_path("/")).is_err()); + assert!(panic::catch_unwind(|| repo_path("/x")).is_err()); + assert!(panic::catch_unwind(|| repo_path("x/")).is_err()); + assert!(panic::catch_unwind(|| repo_path("x//y")).is_err()); + } + + #[test] + fn test_as_internal_file_string() { + assert_eq!(RepoPath::root().as_internal_file_string(), ""); + assert_eq!(repo_path("dir").as_internal_file_string(), "dir"); + assert_eq!(repo_path("dir/file").as_internal_file_string(), "dir/file"); + } + + #[test] + fn test_to_internal_dir_string() { + assert_eq!(RepoPath::root().to_internal_dir_string(), ""); + assert_eq!(repo_path("dir").to_internal_dir_string(), "dir/"); + assert_eq!(repo_path("dir/file").to_internal_dir_string(), "dir/file/"); + } + + #[test] + fn test_starts_with() { + assert!(repo_path("").starts_with(repo_path(""))); + assert!(repo_path("x").starts_with(repo_path(""))); + assert!(!repo_path("").starts_with(repo_path("x"))); + + assert!(repo_path("x").starts_with(repo_path("x"))); + assert!(repo_path("x/y").starts_with(repo_path("x"))); + assert!(!repo_path("xy").starts_with(repo_path("x"))); + assert!(!repo_path("x/y").starts_with(repo_path("y"))); + + assert!(repo_path("x/y").starts_with(repo_path("x/y"))); + assert!(repo_path("x/y/z").starts_with(repo_path("x/y"))); + assert!(!repo_path("x/yz").starts_with(repo_path("x/y"))); + assert!(!repo_path("x").starts_with(repo_path("x/y"))); + assert!(!repo_path("xy").starts_with(repo_path("x/y"))); + } + + #[test] + fn test_strip_prefix() { + assert_eq!( + repo_path("").strip_prefix(repo_path("")), + Some(repo_path("")) + ); + assert_eq!( + repo_path("x").strip_prefix(repo_path("")), + Some(repo_path("x")) + ); + assert_eq!(repo_path("").strip_prefix(repo_path("x")), None); + + assert_eq!( + repo_path("x").strip_prefix(repo_path("x")), + Some(repo_path("")) + ); + assert_eq!( + repo_path("x/y").strip_prefix(repo_path("x")), + Some(repo_path("y")) + ); + assert_eq!(repo_path("xy").strip_prefix(repo_path("x")), None); + assert_eq!(repo_path("x/y").strip_prefix(repo_path("y")), None); + + assert_eq!( + repo_path("x/y").strip_prefix(repo_path("x/y")), + Some(repo_path("")) + ); + assert_eq!( + repo_path("x/y/z").strip_prefix(repo_path("x/y")), + Some(repo_path("z")) + ); + assert_eq!(repo_path("x/yz").strip_prefix(repo_path("x/y")), None); + assert_eq!(repo_path("x").strip_prefix(repo_path("x/y")), None); + assert_eq!(repo_path("xy").strip_prefix(repo_path("x/y")), None); + } + + #[test] + fn test_order() { + assert!(RepoPath::root() < repo_path("dir")); + assert!(repo_path("dir") < repo_path("dirx")); + // '#' < '/', but ["dir", "sub"] < ["dir#"] + assert!(repo_path("dir") < repo_path("dir#")); + assert!(repo_path("dir") < repo_path("dir/sub")); + assert!(repo_path("dir/sub") < repo_path("dir#")); + + assert!(repo_path("abc") < repo_path("dir/file")); + assert!(repo_path("dir") < repo_path("dir/file")); + assert!(repo_path("dis") > repo_path("dir/file")); + assert!(repo_path("xyz") > repo_path("dir/file")); + assert!(repo_path("dir1/xyz") < repo_path("dir2/abc")); + } + + #[test] + fn test_join() { + let root = RepoPath::root(); + let dir = root.join(repo_path_component("dir")); + assert_eq!(dir.as_ref(), repo_path("dir")); + let subdir = dir.join(repo_path_component("subdir")); + assert_eq!(subdir.as_ref(), repo_path("dir/subdir")); + assert_eq!( + subdir.join(repo_path_component("file")).as_ref(), + repo_path("dir/subdir/file") + ); + } + + #[test] + fn test_extend() { + let mut path = RepoPathBuf::root(); + path.extend(std::iter::empty::()); + assert_eq!(path.as_ref(), RepoPath::root()); + path.extend([repo_path_component("dir")]); + assert_eq!(path.as_ref(), repo_path("dir")); + path.extend(std::iter::repeat_n(repo_path_component("subdir"), 3)); + assert_eq!(path.as_ref(), repo_path("dir/subdir/subdir/subdir")); + path.extend(std::iter::empty::()); + assert_eq!(path.as_ref(), repo_path("dir/subdir/subdir/subdir")); + } + + #[test] + fn test_parent() { + let root = RepoPath::root(); + let dir_component = repo_path_component("dir"); + let subdir_component = repo_path_component("subdir"); + + let dir = root.join(dir_component); + let subdir = dir.join(subdir_component); + + assert_eq!(root.parent(), None); + assert_eq!(dir.parent(), Some(root)); + assert_eq!(subdir.parent(), Some(dir.as_ref())); + } + + #[test] + fn test_split() { + let root = RepoPath::root(); + let dir_component = repo_path_component("dir"); + let file_component = repo_path_component("file"); + + let dir = root.join(dir_component); + let file = dir.join(file_component); + + assert_eq!(root.split(), None); + assert_eq!(dir.split(), Some((root, dir_component))); + assert_eq!(file.split(), Some((dir.as_ref(), file_component))); + } + + #[test] + fn test_components() { + assert!(RepoPath::root().components().next().is_none()); + assert_eq!( + repo_path("dir").components().collect_vec(), + vec![repo_path_component("dir")] + ); + assert_eq!( + repo_path("dir/subdir").components().collect_vec(), + vec![repo_path_component("dir"), repo_path_component("subdir")] + ); + + // Iterates from back + assert!(RepoPath::root().components().next_back().is_none()); + assert_eq!( + repo_path("dir").components().rev().collect_vec(), + vec![repo_path_component("dir")] + ); + assert_eq!( + repo_path("dir/subdir").components().rev().collect_vec(), + vec![repo_path_component("subdir"), repo_path_component("dir")] + ); + } + + #[test] + fn test_ancestors() { + assert_eq!( + RepoPath::root().ancestors().collect_vec(), + vec![RepoPath::root()] + ); + assert_eq!( + repo_path("dir").ancestors().collect_vec(), + vec![repo_path("dir"), RepoPath::root()] + ); + assert_eq!( + repo_path("dir/subdir").ancestors().collect_vec(), + vec![repo_path("dir/subdir"), repo_path("dir"), RepoPath::root()] + ); + } + + #[test] + fn test_to_fs_path() { + assert_eq!( + repo_path("").to_fs_path(Path::new("base/dir")).unwrap(), + Path::new("base/dir") + ); + assert_eq!( + repo_path("").to_fs_path(Path::new("")).unwrap(), + Path::new(".") + ); + assert_eq!( + repo_path("file").to_fs_path(Path::new("base/dir")).unwrap(), + Path::new("base/dir/file") + ); + assert_eq!( + repo_path("some/deep/dir/file") + .to_fs_path(Path::new("base/dir")) + .unwrap(), + Path::new("base/dir/some/deep/dir/file") + ); + assert_eq!( + repo_path("dir/file").to_fs_path(Path::new("")).unwrap(), + Path::new("dir/file") + ); + + // Current/parent dir component + assert!(repo_path(".").to_fs_path(Path::new("base")).is_err()); + assert!(repo_path("..").to_fs_path(Path::new("base")).is_err()); + assert!( + repo_path("dir/../file") + .to_fs_path(Path::new("base")) + .is_err() + ); + assert!(repo_path("./file").to_fs_path(Path::new("base")).is_err()); + assert!(repo_path("file/.").to_fs_path(Path::new("base")).is_err()); + assert!(repo_path("../file").to_fs_path(Path::new("base")).is_err()); + assert!(repo_path("file/..").to_fs_path(Path::new("base")).is_err()); + + // Empty component (which is invalid as a repo path) + assert!( + RepoPath::from_internal_string_unchecked("/") + .to_fs_path(Path::new("base")) + .is_err() + ); + assert_eq!( + // Iterator omits empty component after "/", which is fine so long + // as the returned path doesn't escape. + RepoPath::from_internal_string_unchecked("a/") + .to_fs_path(Path::new("base")) + .unwrap(), + Path::new("base/a") + ); + assert!( + RepoPath::from_internal_string_unchecked("/b") + .to_fs_path(Path::new("base")) + .is_err() + ); + assert!( + RepoPath::from_internal_string_unchecked("a//b") + .to_fs_path(Path::new("base")) + .is_err() + ); + + // Component containing slash (simulating Windows path separator) + assert!( + RepoPathComponent::new_unchecked("wind/ows") + .to_fs_name() + .is_err() + ); + assert!( + RepoPathComponent::new_unchecked("./file") + .to_fs_name() + .is_err() + ); + assert!( + RepoPathComponent::new_unchecked("file/.") + .to_fs_name() + .is_err() + ); + assert!(RepoPathComponent::new_unchecked("/").to_fs_name().is_err()); + + // Windows path separator and drive letter + if cfg!(windows) { + assert!( + repo_path(r#"wind\ows"#) + .to_fs_path(Path::new("base")) + .is_err() + ); + assert!( + repo_path(r#".\file"#) + .to_fs_path(Path::new("base")) + .is_err() + ); + assert!( + repo_path(r#"file\."#) + .to_fs_path(Path::new("base")) + .is_err() + ); + assert!( + repo_path(r#"c:/foo"#) + .to_fs_path(Path::new("base")) + .is_err() + ); + } + } + + #[test] + fn test_to_fs_path_unchecked() { + assert_eq!( + repo_path("").to_fs_path_unchecked(Path::new("base/dir")), + Path::new("base/dir") + ); + assert_eq!( + repo_path("").to_fs_path_unchecked(Path::new("")), + Path::new(".") + ); + assert_eq!( + repo_path("file").to_fs_path_unchecked(Path::new("base/dir")), + Path::new("base/dir/file") + ); + assert_eq!( + repo_path("some/deep/dir/file").to_fs_path_unchecked(Path::new("base/dir")), + Path::new("base/dir/some/deep/dir/file") + ); + assert_eq!( + repo_path("dir/file").to_fs_path_unchecked(Path::new("")), + Path::new("dir/file") + ); + } + + #[test] + fn parse_fs_path_wc_in_cwd() { + let temp_dir = new_temp_dir(); + let cwd_path = temp_dir.path().join("repo"); + let wc_path = &cwd_path; + + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "").as_deref(), + Ok(RepoPath::root()) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".").as_deref(), + Ok(RepoPath::root()) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "file").as_deref(), + Ok(repo_path("file")) + ); + // Both slash and the platform's separator are allowed + assert_eq!( + RepoPathBuf::parse_fs_path( + &cwd_path, + wc_path, + format!("dir{}file", std::path::MAIN_SEPARATOR) + ) + .as_deref(), + Ok(repo_path("dir/file")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "dir/file").as_deref(), + Ok(repo_path("dir/file")) + ); + assert_matches!( + RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".."), + Err(FsPathParseError { + source: RelativePathParseError::InvalidComponent { .. }, + .. + }) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo").as_deref(), + Ok(RepoPath::root()) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo/file").as_deref(), + Ok(repo_path("file")) + ); + // Input may be absolute path with ".." + assert_eq!( + RepoPathBuf::parse_fs_path( + &cwd_path, + &cwd_path, + cwd_path.join("../repo").to_str().unwrap() + ) + .as_deref(), + Ok(RepoPath::root()) + ); + } + + #[test] + fn parse_fs_path_wc_in_cwd_parent() { + let temp_dir = new_temp_dir(); + let cwd_path = temp_dir.path().join("dir"); + let wc_path = cwd_path.parent().unwrap().to_path_buf(); + + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "").as_deref(), + Ok(repo_path("dir")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ".").as_deref(), + Ok(repo_path("dir")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "file").as_deref(), + Ok(repo_path("dir/file")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "subdir/file").as_deref(), + Ok(repo_path("dir/subdir/file")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "..").as_deref(), + Ok(RepoPath::root()) + ); + assert_matches!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../.."), + Err(FsPathParseError { + source: RelativePathParseError::InvalidComponent { .. }, + .. + }) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../other-dir/file").as_deref(), + Ok(repo_path("other-dir/file")) + ); + } + + #[test] + fn parse_fs_path_wc_in_cwd_child() { + let temp_dir = new_temp_dir(); + let cwd_path = temp_dir.path().join("cwd"); + let wc_path = cwd_path.join("repo"); + + assert_matches!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ""), + Err(FsPathParseError { + source: RelativePathParseError::InvalidComponent { .. }, + .. + }) + ); + assert_matches!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "not-repo"), + Err(FsPathParseError { + source: RelativePathParseError::InvalidComponent { .. }, + .. + }) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo").as_deref(), + Ok(RepoPath::root()) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/file").as_deref(), + Ok(repo_path("file")) + ); + assert_eq!( + RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/dir/file").as_deref(), + Ok(repo_path("dir/file")) + ); + } + + #[test] + fn test_split_common_prefix() { + assert_eq!( + repo_path("foo/bar").split_common_prefix(repo_path("foo/bar/baz")), + (repo_path("foo/bar"), repo_path("")) + ); + + assert_eq!( + repo_path("foo/bar/baz").split_common_prefix(repo_path("foo/bar")), + (repo_path("foo/bar"), repo_path("baz")) + ); + + assert_eq!( + repo_path("foo/bar/bing").split_common_prefix(repo_path("foo/bar/baz")), + (repo_path("foo/bar"), repo_path("bing")) + ); + + assert_eq!( + repo_path("no/common/prefix").split_common_prefix(repo_path("foo/bar/baz")), + (RepoPath::root(), repo_path("no/common/prefix")) + ); + + assert_eq!( + repo_path("same/path").split_common_prefix(repo_path("same/path")), + (repo_path("same/path"), RepoPath::root()) + ); + + assert_eq!( + RepoPath::root().split_common_prefix(repo_path("foo")), + (RepoPath::root(), RepoPath::root()) + ); + + assert_eq!( + RepoPath::root().split_common_prefix(RepoPath::root()), + (RepoPath::root(), RepoPath::root()) + ); + + assert_eq!( + repo_path("foo/bar").split_common_prefix(RepoPath::root()), + (RepoPath::root(), repo_path("foo/bar")) + ); + } +} diff --git a/lib/proc-macros/Cargo.toml b/lib/proc-macros/Cargo.toml index 8f2c0003421..29cd7d66ede 100644 --- a/lib/proc-macros/Cargo.toml +++ b/lib/proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jj-lib-proc-macros" -description = "Proc macros for the jj-lib crate" +description = "Proc macros for the jj-lib crate (deprecated use jj-core-proc-macros instead)" version = { workspace = true } edition = { workspace = true } @@ -17,9 +17,6 @@ include = ["/LICENSE", "/src/"] proc-macro = true [dependencies] -proc-macro2 = { workspace = true } -quote = { workspace = true } -syn = { workspace = true } [lints] workspace = true diff --git a/lib/proc-macros/src/lib.rs b/lib/proc-macros/src/lib.rs index 83937aece71..aff5e7133bd 100644 --- a/lib/proc-macros/src/lib.rs +++ b/lib/proc-macros/src/lib.rs @@ -1,38 +1,2 @@ -mod content_hash; - -extern crate proc_macro; - -use quote::quote; -use syn::DeriveInput; -use syn::parse_macro_input; - -/// Derive macro generating an impl of the trait `ContentHash`. -/// -/// Derives the `ContentHash` trait for a struct by calling `ContentHash::hash` -/// on each of the struct members in the order that they're declared. All -/// members of the struct must implement the `ContentHash` trait. -#[proc_macro_derive(ContentHash)] -pub fn derive_content_hash(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - // The name of the struct. - let name = &input.ident; - - // Generate an expression to hash each of the fields in the struct. - let hash_impl = content_hash::generate_hash_impl(&input.data); - - // Handle structs and enums with generics. - let generics = content_hash::add_trait_bounds(input.generics); - let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - - let expanded = quote! { - #[automatically_derived] - impl #impl_generics ::jj_lib::content_hash::ContentHash for #name #ty_generics - #where_clause { - fn hash(&self, state: &mut impl ::jj_lib::content_hash::DigestUpdate) { - #hash_impl - } - } - }; - expanded.into() -} +//! Don't use this crate, use `jj-core-proc-macros` instead since it now +//! contains the `ContentHash` macro. diff --git a/lib/src/content_hash.rs b/lib/src/content_hash.rs index 0137ac009c9..6b3e7fee66d 100644 --- a/lib/src/content_hash.rs +++ b/lib/src/content_hash.rs @@ -1,255 +1,20 @@ //! Portable, stable hashing suitable for identifying values -use blake2::Blake2b512; // Re-export DigestUpdate so that the ContentHash proc macro can be used in // external crates without directly depending on the digest crate. pub use digest::Update as DigestUpdate; -use itertools::Itertools as _; -pub use jj_lib_proc_macros::ContentHash; - -/// Portable, stable hashing suitable for identifying values -/// -/// Variable-length sequences should hash a 64-bit little-endian representation -/// of their length, then their elements in order. Unordered containers should -/// order their elements according to their `Ord` implementation. Enums should -/// hash a 32-bit little-endian encoding of the ordinal number of the enum -/// variant, then the variant's fields in lexical order. -/// -/// Structs can implement `ContentHash` by using `#[derive(ContentHash)]`. -pub trait ContentHash { - /// Update the hasher state with this object's content - fn hash(&self, state: &mut impl DigestUpdate); -} - -/// The 512-bit BLAKE2b content hash -pub fn blake2b_hash(x: &(impl ContentHash + ?Sized)) -> digest::Output { - use digest::Digest as _; - let mut hasher = Blake2b512::default(); - x.hash(&mut hasher); - hasher.finalize() -} - -impl ContentHash for () { - fn hash(&self, _: &mut impl DigestUpdate) {} -} - -macro_rules! tuple_impls { - ($( ( $($n:tt $T:ident),+ ) )+) => { - $( - impl<$($T: ContentHash,)+> ContentHash for ($($T,)+) { - fn hash(&self, state: &mut impl DigestUpdate) { - $(self.$n.hash(state);)+ - } - } - )+ - } -} - -tuple_impls! { - (0 T0) - (0 T0, 1 T1) - (0 T0, 1 T1, 2 T2) - (0 T0, 1 T1, 2 T2, 3 T3) -} - -impl ContentHash for bool { - fn hash(&self, state: &mut impl DigestUpdate) { - u8::from(*self).hash(state); - } -} - -impl ContentHash for u8 { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&[*self]); - } -} - -impl ContentHash for u32 { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&self.to_le_bytes()); - } -} - -impl ContentHash for i32 { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&self.to_le_bytes()); - } -} - -impl ContentHash for u64 { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&self.to_le_bytes()); - } -} - -impl ContentHash for i64 { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&self.to_le_bytes()); - } -} - -// TODO: Specialize for [u8] once specialization exists -impl ContentHash for [T] { - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&(self.len() as u64).to_le_bytes()); - for x in self { - x.hash(state); - } - } -} - -impl ContentHash for Vec { - fn hash(&self, state: &mut impl DigestUpdate) { - self.as_slice().hash(state); - } -} - -impl ContentHash for str { - fn hash(&self, state: &mut impl DigestUpdate) { - self.as_bytes().hash(state); - } -} - -impl ContentHash for String { - fn hash(&self, state: &mut impl DigestUpdate) { - self.as_str().hash(state); - } -} - -impl ContentHash for Option { - fn hash(&self, state: &mut impl DigestUpdate) { - match self { - None => state.update(&0u32.to_le_bytes()), - Some(x) => { - state.update(&1u32.to_le_bytes()); - x.hash(state); - } - } - } -} - -impl ContentHash for std::collections::HashMap -where - K: ContentHash + Ord, - V: ContentHash, -{ - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&(self.len() as u64).to_le_bytes()); - let mut kv = self.iter().collect_vec(); - kv.sort_unstable_by_key(|&(k, _)| k); - for (k, v) in kv { - k.hash(state); - v.hash(state); - } - } -} - -impl ContentHash for std::collections::HashSet -where - K: ContentHash + Ord, -{ - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&(self.len() as u64).to_le_bytes()); - for k in self.iter().sorted() { - k.hash(state); - } - } -} - -impl ContentHash for std::collections::BTreeMap -where - K: ContentHash, - V: ContentHash, -{ - fn hash(&self, state: &mut impl DigestUpdate) { - state.update(&(self.len() as u64).to_le_bytes()); - for (k, v) in self { - k.hash(state); - v.hash(state); - } - } -} +pub use jj_core::content_hash::ContentHash; +pub use jj_core::content_hash::blake2b_hash; #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::collections::HashMap; +mod test { + + use blake2::Blake2b512; use super::*; use crate::hex_util; - #[test] - fn test_string_sanity() { - let a = "a".to_string(); - let b = "b".to_string(); - assert_eq!(hash(&a), hash(&a.clone())); - assert_ne!(hash(&a), hash(&b)); - assert_ne!(hash(&"a".to_string()), hash(&"a\0".to_string())); - } - - #[test] - fn test_hash_map_key_value_distinction() { - let a = [("ab".to_string(), "cd".to_string())] - .into_iter() - .collect::>(); - let b = [("a".to_string(), "bcd".to_string())] - .into_iter() - .collect::>(); - - assert_ne!(hash(&a), hash(&b)); - } - - #[test] - fn test_btree_map_key_value_distinction() { - let a = [("ab".to_string(), "cd".to_string())] - .into_iter() - .collect::>(); - let b = [("a".to_string(), "bcd".to_string())] - .into_iter() - .collect::>(); - - assert_ne!(hash(&a), hash(&b)); - } - - #[test] - fn test_tuple_sanity() { - #[derive(ContentHash)] - struct T1(i32); - #[derive(ContentHash)] - struct T2(i32, i32); - #[derive(ContentHash)] - struct T3(i32, i32, i32); - #[derive(ContentHash)] - struct T4(i32, i32, i32, i32); - assert_eq!(hash(&T1(0)), hash(&(0,))); - assert_eq!(hash(&T2(0, 1)), hash(&(0, 1))); - assert_eq!(hash(&T3(0, 1, 2)), hash(&(0, 1, 2))); - assert_eq!(hash(&T4(0, 1, 2, 3)), hash(&(0, 1, 2, 3))); - } - - #[test] - fn test_struct_sanity() { - #[derive(ContentHash)] - struct Foo { - x: i32, - } - assert_ne!(hash(&Foo { x: 42 }), hash(&Foo { x: 12 })); - } - - #[test] - fn test_option_sanity() { - assert_ne!(hash(&Some(42)), hash(&42)); - assert_ne!(hash(&None::), hash(&42i32)); - } - - #[test] - fn test_slice_sanity() { - assert_ne!(hash(&[42i32][..]), hash(&[12i32][..])); - assert_ne!(hash(&([] as [i32; 0])[..]), hash(&[42i32][..])); - assert_ne!(hash(&([] as [i32; 0])[..]), hash(&())); - assert_ne!(hash(&42i32), hash(&[42i32][..])); - } - + // TODO: move this over when we lower `hex_util.rs` #[test] fn test_consistent_hashing() { #[derive(ContentHash)] @@ -281,19 +46,6 @@ mod tests { ); } - // Test that the derived version of `ContentHash` matches the that's - // manually implemented for `std::Option`. - #[test] - fn derive_for_enum() { - #[derive(ContentHash)] - enum MyOption { - None, - Some(T), - } - assert_eq!(hash(&Option::::None), hash(&MyOption::::None)); - assert_eq!(hash(&Some(1)), hash(&MyOption::Some(1))); - } - fn hash(x: &(impl ContentHash + ?Sized)) -> digest::Output { blake2b_hash(x) } diff --git a/lib/src/repo_path.rs b/lib/src/repo_path.rs index 930f01c2992..7c22ac265d8 100644 --- a/lib/src/repo_path.rs +++ b/lib/src/repo_path.rs @@ -14,640 +14,28 @@ #![expect(missing_docs)] -use std::borrow::Borrow; -use std::cmp::Ordering; use std::collections::HashMap; use std::fmt; use std::fmt::Debug; -use std::fmt::Formatter; use std::iter; -use std::iter::FusedIterator; -use std::ops::Deref; -use std::path::Component; -use std::path::Path; use std::path::PathBuf; use itertools::Itertools as _; -use ref_cast::RefCastCustom; -use ref_cast::ref_cast_custom; +pub use jj_core::repo_path::FsPathParseError; +pub use jj_core::repo_path::InvalidNewRepoPathError; +pub use jj_core::repo_path::InvalidRepoPathComponentError; +pub use jj_core::repo_path::InvalidRepoPathError; +pub use jj_core::repo_path::RelativePathParseError; +pub use jj_core::repo_path::RepoPath; +pub use jj_core::repo_path::RepoPathBuf; +pub use jj_core::repo_path::RepoPathComponent; +pub use jj_core::repo_path::RepoPathComponentBuf; +pub use jj_core::repo_path::RepoPathComponentsIter; use thiserror::Error; -use crate::content_hash::ContentHash; use crate::file_util; use crate::merge::Diff; -/// Owned `RepoPath` component. -#[derive(ContentHash, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RepoPathComponentBuf { - // Don't add more fields. Eq, Hash, and Ord must be compatible with the - // borrowed RepoPathComponent type. - value: String, -} - -impl RepoPathComponentBuf { - /// Wraps `value` as `RepoPathComponentBuf`. - /// - /// Returns an error if the input `value` is empty or contains path - /// separator. - pub fn new(value: impl Into) -> Result { - let value: String = value.into(); - if is_valid_repo_path_component_str(&value) { - Ok(Self { value }) - } else { - Err(InvalidNewRepoPathError { value }) - } - } -} - -/// Borrowed `RepoPath` component. -#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom)] -#[repr(transparent)] -pub struct RepoPathComponent { - value: str, -} - -impl RepoPathComponent { - /// Wraps `value` as `RepoPathComponent`. - /// - /// Returns an error if the input `value` is empty or contains path - /// separator. - pub fn new(value: &str) -> Result<&Self, InvalidNewRepoPathError> { - if is_valid_repo_path_component_str(value) { - Ok(Self::new_unchecked(value)) - } else { - Err(InvalidNewRepoPathError { - value: value.to_string(), - }) - } - } - - #[ref_cast_custom] - const fn new_unchecked(value: &str) -> &Self; - - /// Returns the underlying string representation. - pub fn as_internal_str(&self) -> &str { - &self.value - } - - /// Returns a normal filesystem entry name if this path component is valid - /// as a file/directory name. - pub fn to_fs_name(&self) -> Result<&str, InvalidRepoPathComponentError> { - let mut components = Path::new(&self.value).components().fuse(); - match (components.next(), components.next()) { - // Trailing "." can be normalized by Path::components(), so compare - // component name. e.g. "foo\." (on Windows) should be rejected. - (Some(Component::Normal(name)), None) if name == &self.value => Ok(&self.value), - // e.g. ".", "..", "foo\bar" (on Windows) - _ => Err(InvalidRepoPathComponentError { - component: self.value.into(), - }), - } - } -} - -impl Debug for RepoPathComponent { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", &self.value) - } -} - -impl Debug for RepoPathComponentBuf { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - ::fmt(self, f) - } -} - -impl AsRef for RepoPathComponent { - fn as_ref(&self) -> &Self { - self - } -} - -impl AsRef for RepoPathComponentBuf { - fn as_ref(&self) -> &RepoPathComponent { - self - } -} - -impl Borrow for RepoPathComponentBuf { - fn borrow(&self) -> &RepoPathComponent { - self - } -} - -impl Deref for RepoPathComponentBuf { - type Target = RepoPathComponent; - - fn deref(&self) -> &Self::Target { - RepoPathComponent::new_unchecked(&self.value) - } -} - -impl ToOwned for RepoPathComponent { - type Owned = RepoPathComponentBuf; - - fn to_owned(&self) -> Self::Owned { - let value = self.value.to_owned(); - RepoPathComponentBuf { value } - } - - fn clone_into(&self, target: &mut Self::Owned) { - self.value.clone_into(&mut target.value); - } -} - -/// Iterator over `RepoPath` components. -#[derive(Clone, Debug)] -pub struct RepoPathComponentsIter<'a> { - value: &'a str, -} - -impl<'a> RepoPathComponentsIter<'a> { - /// Returns the remaining part as repository path. - pub fn as_path(&self) -> &'a RepoPath { - RepoPath::from_internal_string_unchecked(self.value) - } -} - -impl<'a> Iterator for RepoPathComponentsIter<'a> { - type Item = &'a RepoPathComponent; - - fn next(&mut self) -> Option { - if self.value.is_empty() { - return None; - } - let (name, remainder) = self - .value - .split_once('/') - .unwrap_or_else(|| (self.value, &self.value[self.value.len()..])); - self.value = remainder; - Some(RepoPathComponent::new_unchecked(name)) - } -} - -impl DoubleEndedIterator for RepoPathComponentsIter<'_> { - fn next_back(&mut self) -> Option { - if self.value.is_empty() { - return None; - } - let (remainder, name) = self - .value - .rsplit_once('/') - .unwrap_or_else(|| (&self.value[..0], self.value)); - self.value = remainder; - Some(RepoPathComponent::new_unchecked(name)) - } -} - -impl FusedIterator for RepoPathComponentsIter<'_> {} - -/// Owned repository path. -#[derive(ContentHash, Clone, Eq, Hash, PartialEq, serde::Serialize)] -#[serde(transparent)] -pub struct RepoPathBuf { - // Don't add more fields. Eq, Hash, and Ord must be compatible with the - // borrowed RepoPath type. - value: String, -} - -/// Borrowed repository path. -#[derive(ContentHash, Eq, Hash, PartialEq, RefCastCustom, serde::Serialize)] -#[repr(transparent)] -#[serde(transparent)] -pub struct RepoPath { - value: str, -} - -impl Debug for RepoPath { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", &self.value) - } -} - -impl Debug for RepoPathBuf { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - ::fmt(self, f) - } -} - -/// The `value` is not a valid repo path because it contains empty path -/// component. For example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all -/// invalid. -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error(r#"Invalid repo path input "{value}""#)] -pub struct InvalidNewRepoPathError { - value: String, -} - -impl RepoPathBuf { - /// Creates owned repository path pointing to the root. - pub const fn root() -> Self { - Self { - value: String::new(), - } - } - - /// Creates `RepoPathBuf` from valid string representation. - pub fn from_internal_string(value: impl Into) -> Result { - let value: String = value.into(); - if is_valid_repo_path_str(&value) { - Ok(Self { value }) - } else { - Err(InvalidNewRepoPathError { value }) - } - } - - /// Converts repo-relative `Path` to `RepoPathBuf`. - /// - /// The input path should not contain redundant `.` or `..`. - pub fn from_relative_path( - relative_path: impl AsRef, - ) -> Result { - let relative_path = relative_path.as_ref(); - if relative_path == Path::new(".") { - return Ok(Self::root()); - } - - let mut components = relative_path - .components() - .map(|c| match c { - Component::Normal(name) => { - name.to_str() - .ok_or_else(|| RelativePathParseError::InvalidUtf8 { - path: relative_path.into(), - }) - } - _ => Err(RelativePathParseError::InvalidComponent { - component: c.as_os_str().to_string_lossy().into(), - path: relative_path.into(), - }), - }) - .fuse(); - let mut value = String::with_capacity(relative_path.as_os_str().len()); - if let Some(name) = components.next() { - value.push_str(name?); - } - for name in components { - value.push('/'); - value.push_str(name?); - } - Ok(Self { value }) - } - - /// Parses an `input` path into a `RepoPathBuf` relative to `base`. - /// - /// The `cwd` and `base` paths are supposed to be absolute and normalized in - /// the same manner. The `input` path may be either relative to `cwd` or - /// absolute. - pub fn parse_fs_path( - cwd: &Path, - base: &Path, - input: impl AsRef, - ) -> Result { - let input = input.as_ref(); - let abs_input_path = file_util::normalize_path(&cwd.join(input)); - let repo_relative_path = file_util::relative_path(base, &abs_input_path); - Self::from_relative_path(repo_relative_path).map_err(|source| FsPathParseError { - base: file_util::relative_path(cwd, base).into(), - input: input.into(), - source, - }) - } - - /// Consumes this and returns the underlying string representation. - pub fn into_internal_string(self) -> String { - self.value - } -} - -impl RepoPath { - /// Returns repository path pointing to the root. - pub const fn root() -> &'static Self { - Self::from_internal_string_unchecked("") - } - - /// Wraps valid string representation as `RepoPath`. - /// - /// Returns an error if the input `value` contains empty path component. For - /// example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all invalid. - pub fn from_internal_string(value: &str) -> Result<&Self, InvalidNewRepoPathError> { - if is_valid_repo_path_str(value) { - Ok(Self::from_internal_string_unchecked(value)) - } else { - Err(InvalidNewRepoPathError { - value: value.to_owned(), - }) - } - } - - #[ref_cast_custom] - const fn from_internal_string_unchecked(value: &str) -> &Self; - - /// The full string form used internally, not for presenting to users (where - /// we may want to use the platform's separator). This format includes a - /// trailing slash, unless this path represents the root directory. That - /// way it can be concatenated with a basename and produce a valid path. - pub fn to_internal_dir_string(&self) -> String { - if self.value.is_empty() { - String::new() - } else { - [&self.value, "/"].concat() - } - } - - /// The full string form used internally, not for presenting to users (where - /// we may want to use the platform's separator). - pub fn as_internal_file_string(&self) -> &str { - &self.value - } - - /// Converts repository path to filesystem path relative to the `base`. - /// - /// The returned path should never contain `..`, `C:` (on Windows), etc. - /// However, it may contain reserved working-copy directories such as `.jj`. - pub fn to_fs_path(&self, base: &Path) -> Result { - let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1); - result.push(base); - for c in self.components() { - result.push(c.to_fs_name().map_err(|err| err.with_path(self))?); - } - if result.as_os_str().is_empty() { - result.push("."); - } - Ok(result) - } - - /// Converts repository path to filesystem path relative to the `base`, - /// without checking invalid path components. - /// - /// The returned path may point outside of the `base` directory. Use this - /// function only for displaying or testing purposes. - pub fn to_fs_path_unchecked(&self, base: &Path) -> PathBuf { - let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1); - result.push(base); - result.extend(self.components().map(RepoPathComponent::as_internal_str)); - if result.as_os_str().is_empty() { - result.push("."); - } - result - } - - pub fn is_root(&self) -> bool { - self.value.is_empty() - } - - /// Returns true if the `base` is a prefix of this path. - pub fn starts_with(&self, base: &Self) -> bool { - self.strip_prefix(base).is_some() - } - - /// Returns the remaining path with the `base` path removed. - pub fn strip_prefix(&self, base: &Self) -> Option<&Self> { - if base.value.is_empty() { - Some(self) - } else { - let tail = self.value.strip_prefix(&base.value)?; - if tail.is_empty() { - Some(Self::from_internal_string_unchecked(tail)) - } else { - tail.strip_prefix('/') - .map(Self::from_internal_string_unchecked) - } - } - } - - /// Returns the parent path without the base name component. - pub fn parent(&self) -> Option<&Self> { - self.split().map(|(parent, _)| parent) - } - - /// Splits this into the parent path and base name component. - pub fn split(&self) -> Option<(&Self, &RepoPathComponent)> { - let mut components = self.components(); - let basename = components.next_back()?; - Some((components.as_path(), basename)) - } - - pub fn components(&self) -> RepoPathComponentsIter<'_> { - RepoPathComponentsIter { value: &self.value } - } - - pub fn ancestors(&self) -> impl Iterator { - std::iter::successors(Some(self), |path| path.parent()) - } - - pub fn join(&self, entry: &RepoPathComponent) -> RepoPathBuf { - let value = if self.value.is_empty() { - entry.as_internal_str().to_owned() - } else { - [&self.value, "/", entry.as_internal_str()].concat() - }; - RepoPathBuf { value } - } - - /// Splits this path at its common prefix with `other`. - /// - /// # Returns - /// - /// Returns the `(common_prefix, self_remainder)`. - /// - /// All paths will at least have `RepoPath::root()` as a common prefix, - /// therefore even if `self` and `other` have no matching parent component - /// this function will always return at least `(RepoPath::root(), self)`. - /// - /// - /// # Examples - /// - /// ``` - /// use jj_lib::repo_path::RepoPath; - /// - /// let bing_path = RepoPath::from_internal_string("foo/bar/bing").unwrap(); - /// - /// let baz_path = RepoPath::from_internal_string("foo/bar/baz").unwrap(); - /// - /// let foo_bar_path = RepoPath::from_internal_string("foo/bar").unwrap(); - /// - /// assert_eq!( - /// bing_path.split_common_prefix(&baz_path), - /// (foo_bar_path, RepoPath::from_internal_string("bing").unwrap()) - /// ); - /// - /// let unrelated_path = RepoPath::from_internal_string("no/common/prefix").unwrap(); - /// assert_eq!( - /// baz_path.split_common_prefix(&unrelated_path), - /// (RepoPath::root(), baz_path) - /// ); - /// ``` - pub fn split_common_prefix(&self, other: &Self) -> (&Self, &Self) { - // Obtain the common prefix between these paths - let mut prefix_len = 0; - - let common_components = self - .components() - .zip(other.components()) - .take_while(|(prev_comp, this_comp)| prev_comp == this_comp); - - for (self_comp, _other_comp) in common_components { - if prefix_len > 0 { - // + 1 for all paths to take their separators into account. - // We skip the first one since there are ComponentCount - 1 separators in a - // path. - prefix_len += 1; - } - - prefix_len += self_comp.value.len(); - } - - if prefix_len == 0 { - // No common prefix except root - return (Self::root(), self); - } - - if prefix_len == self.value.len() { - return (self, Self::root()); - } - - let common_prefix = Self::from_internal_string_unchecked(&self.value[..prefix_len]); - let remainder = Self::from_internal_string_unchecked(&self.value[prefix_len + 1..]); - - (common_prefix, remainder) - } -} - -impl AsRef for RepoPath { - fn as_ref(&self) -> &Self { - self - } -} - -impl AsRef for RepoPathBuf { - fn as_ref(&self) -> &RepoPath { - self - } -} - -impl Borrow for RepoPathBuf { - fn borrow(&self) -> &RepoPath { - self - } -} - -impl Deref for RepoPathBuf { - type Target = RepoPath; - - fn deref(&self) -> &Self::Target { - RepoPath::from_internal_string_unchecked(&self.value) - } -} - -impl ToOwned for RepoPath { - type Owned = RepoPathBuf; - - fn to_owned(&self) -> Self::Owned { - let value = self.value.to_owned(); - RepoPathBuf { value } - } - - fn clone_into(&self, target: &mut Self::Owned) { - self.value.clone_into(&mut target.value); - } -} - -impl Ord for RepoPath { - fn cmp(&self, other: &Self) -> Ordering { - // If there were leading/trailing slash, components-based Ord would - // disagree with str-based Eq. - debug_assert!(is_valid_repo_path_str(&self.value)); - self.components().cmp(other.components()) - } -} - -impl Ord for RepoPathBuf { - fn cmp(&self, other: &Self) -> Ordering { - ::cmp(self, other) - } -} - -impl PartialOrd for RepoPath { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl PartialOrd for RepoPathBuf { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl> Extend

for RepoPathBuf { - fn extend>(&mut self, iter: T) { - for component in iter { - if !self.value.is_empty() { - self.value.push('/'); - } - self.value.push_str(component.as_ref().as_internal_str()); - } - } -} - -/// `RepoPath` contained invalid file/directory component such as `..`. -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error(r#"Invalid repository path "{}""#, path.as_internal_file_string())] -pub struct InvalidRepoPathError { - /// Path containing an error. - pub path: RepoPathBuf, - /// Source error. - pub source: InvalidRepoPathComponentError, -} - -/// `RepoPath` component was invalid. (e.g. `..`) -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error(r#"Invalid path component "{component}""#)] -pub struct InvalidRepoPathComponentError { - pub component: Box, -} - -impl InvalidRepoPathComponentError { - /// Attaches the `path` that caused the error. - pub fn with_path(self, path: &RepoPath) -> InvalidRepoPathError { - InvalidRepoPathError { - path: path.to_owned(), - source: self, - } - } -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -pub enum RelativePathParseError { - #[error(r#"Invalid component "{component}" in repo-relative path "{path}""#)] - InvalidComponent { - component: Box, - path: Box, - }, - #[error(r#"Not valid UTF-8 path "{path}""#)] - InvalidUtf8 { path: Box }, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error(r#"Path "{input}" is not in the repo "{base}""#)] -pub struct FsPathParseError { - /// Repository or workspace root path relative to the `cwd`. - pub base: Box, - /// Input path without normalization. - pub input: Box, - /// Source error. - pub source: RelativePathParseError, -} - -fn is_valid_repo_path_component_str(value: &str) -> bool { - !value.is_empty() && !value.contains('/') -} - -fn is_valid_repo_path_str(value: &str) -> bool { - !value.starts_with('/') && !value.ends_with('/') && !value.contains("//") -} - /// An error from `RepoPathUiConverter::parse_file_path`. #[derive(Debug, Error)] pub enum UiPathParseError { @@ -839,485 +227,12 @@ impl Debug for RepoPathTree { #[cfg(test)] mod tests { - use std::panic; - - use assert_matches::assert_matches; - use itertools::Itertools as _; use super::*; - use crate::tests::new_temp_dir; - fn repo_path(value: &str) -> &RepoPath { RepoPath::from_internal_string(value).unwrap() } - fn repo_path_component(value: &str) -> &RepoPathComponent { - RepoPathComponent::new(value).unwrap() - } - - #[test] - fn test_is_root() { - assert!(RepoPath::root().is_root()); - assert!(repo_path("").is_root()); - assert!(!repo_path("foo").is_root()); - } - - #[test] - fn test_from_internal_string() { - let repo_path_buf = |value: &str| RepoPathBuf::from_internal_string(value).unwrap(); - assert_eq!(repo_path_buf(""), RepoPathBuf::root()); - assert!(panic::catch_unwind(|| repo_path_buf("/")).is_err()); - assert!(panic::catch_unwind(|| repo_path_buf("/x")).is_err()); - assert!(panic::catch_unwind(|| repo_path_buf("x/")).is_err()); - assert!(panic::catch_unwind(|| repo_path_buf("x//y")).is_err()); - - assert_eq!(repo_path(""), RepoPath::root()); - assert!(panic::catch_unwind(|| repo_path("/")).is_err()); - assert!(panic::catch_unwind(|| repo_path("/x")).is_err()); - assert!(panic::catch_unwind(|| repo_path("x/")).is_err()); - assert!(panic::catch_unwind(|| repo_path("x//y")).is_err()); - } - - #[test] - fn test_as_internal_file_string() { - assert_eq!(RepoPath::root().as_internal_file_string(), ""); - assert_eq!(repo_path("dir").as_internal_file_string(), "dir"); - assert_eq!(repo_path("dir/file").as_internal_file_string(), "dir/file"); - } - - #[test] - fn test_to_internal_dir_string() { - assert_eq!(RepoPath::root().to_internal_dir_string(), ""); - assert_eq!(repo_path("dir").to_internal_dir_string(), "dir/"); - assert_eq!(repo_path("dir/file").to_internal_dir_string(), "dir/file/"); - } - - #[test] - fn test_starts_with() { - assert!(repo_path("").starts_with(repo_path(""))); - assert!(repo_path("x").starts_with(repo_path(""))); - assert!(!repo_path("").starts_with(repo_path("x"))); - - assert!(repo_path("x").starts_with(repo_path("x"))); - assert!(repo_path("x/y").starts_with(repo_path("x"))); - assert!(!repo_path("xy").starts_with(repo_path("x"))); - assert!(!repo_path("x/y").starts_with(repo_path("y"))); - - assert!(repo_path("x/y").starts_with(repo_path("x/y"))); - assert!(repo_path("x/y/z").starts_with(repo_path("x/y"))); - assert!(!repo_path("x/yz").starts_with(repo_path("x/y"))); - assert!(!repo_path("x").starts_with(repo_path("x/y"))); - assert!(!repo_path("xy").starts_with(repo_path("x/y"))); - } - - #[test] - fn test_strip_prefix() { - assert_eq!( - repo_path("").strip_prefix(repo_path("")), - Some(repo_path("")) - ); - assert_eq!( - repo_path("x").strip_prefix(repo_path("")), - Some(repo_path("x")) - ); - assert_eq!(repo_path("").strip_prefix(repo_path("x")), None); - - assert_eq!( - repo_path("x").strip_prefix(repo_path("x")), - Some(repo_path("")) - ); - assert_eq!( - repo_path("x/y").strip_prefix(repo_path("x")), - Some(repo_path("y")) - ); - assert_eq!(repo_path("xy").strip_prefix(repo_path("x")), None); - assert_eq!(repo_path("x/y").strip_prefix(repo_path("y")), None); - - assert_eq!( - repo_path("x/y").strip_prefix(repo_path("x/y")), - Some(repo_path("")) - ); - assert_eq!( - repo_path("x/y/z").strip_prefix(repo_path("x/y")), - Some(repo_path("z")) - ); - assert_eq!(repo_path("x/yz").strip_prefix(repo_path("x/y")), None); - assert_eq!(repo_path("x").strip_prefix(repo_path("x/y")), None); - assert_eq!(repo_path("xy").strip_prefix(repo_path("x/y")), None); - } - - #[test] - fn test_order() { - assert!(RepoPath::root() < repo_path("dir")); - assert!(repo_path("dir") < repo_path("dirx")); - // '#' < '/', but ["dir", "sub"] < ["dir#"] - assert!(repo_path("dir") < repo_path("dir#")); - assert!(repo_path("dir") < repo_path("dir/sub")); - assert!(repo_path("dir/sub") < repo_path("dir#")); - - assert!(repo_path("abc") < repo_path("dir/file")); - assert!(repo_path("dir") < repo_path("dir/file")); - assert!(repo_path("dis") > repo_path("dir/file")); - assert!(repo_path("xyz") > repo_path("dir/file")); - assert!(repo_path("dir1/xyz") < repo_path("dir2/abc")); - } - - #[test] - fn test_join() { - let root = RepoPath::root(); - let dir = root.join(repo_path_component("dir")); - assert_eq!(dir.as_ref(), repo_path("dir")); - let subdir = dir.join(repo_path_component("subdir")); - assert_eq!(subdir.as_ref(), repo_path("dir/subdir")); - assert_eq!( - subdir.join(repo_path_component("file")).as_ref(), - repo_path("dir/subdir/file") - ); - } - - #[test] - fn test_extend() { - let mut path = RepoPathBuf::root(); - path.extend(std::iter::empty::()); - assert_eq!(path.as_ref(), RepoPath::root()); - path.extend([repo_path_component("dir")]); - assert_eq!(path.as_ref(), repo_path("dir")); - path.extend(std::iter::repeat_n(repo_path_component("subdir"), 3)); - assert_eq!(path.as_ref(), repo_path("dir/subdir/subdir/subdir")); - path.extend(std::iter::empty::()); - assert_eq!(path.as_ref(), repo_path("dir/subdir/subdir/subdir")); - } - - #[test] - fn test_parent() { - let root = RepoPath::root(); - let dir_component = repo_path_component("dir"); - let subdir_component = repo_path_component("subdir"); - - let dir = root.join(dir_component); - let subdir = dir.join(subdir_component); - - assert_eq!(root.parent(), None); - assert_eq!(dir.parent(), Some(root)); - assert_eq!(subdir.parent(), Some(dir.as_ref())); - } - - #[test] - fn test_split() { - let root = RepoPath::root(); - let dir_component = repo_path_component("dir"); - let file_component = repo_path_component("file"); - - let dir = root.join(dir_component); - let file = dir.join(file_component); - - assert_eq!(root.split(), None); - assert_eq!(dir.split(), Some((root, dir_component))); - assert_eq!(file.split(), Some((dir.as_ref(), file_component))); - } - - #[test] - fn test_components() { - assert!(RepoPath::root().components().next().is_none()); - assert_eq!( - repo_path("dir").components().collect_vec(), - vec![repo_path_component("dir")] - ); - assert_eq!( - repo_path("dir/subdir").components().collect_vec(), - vec![repo_path_component("dir"), repo_path_component("subdir")] - ); - - // Iterates from back - assert!(RepoPath::root().components().next_back().is_none()); - assert_eq!( - repo_path("dir").components().rev().collect_vec(), - vec![repo_path_component("dir")] - ); - assert_eq!( - repo_path("dir/subdir").components().rev().collect_vec(), - vec![repo_path_component("subdir"), repo_path_component("dir")] - ); - } - - #[test] - fn test_ancestors() { - assert_eq!( - RepoPath::root().ancestors().collect_vec(), - vec![RepoPath::root()] - ); - assert_eq!( - repo_path("dir").ancestors().collect_vec(), - vec![repo_path("dir"), RepoPath::root()] - ); - assert_eq!( - repo_path("dir/subdir").ancestors().collect_vec(), - vec![repo_path("dir/subdir"), repo_path("dir"), RepoPath::root()] - ); - } - - #[test] - fn test_to_fs_path() { - assert_eq!( - repo_path("").to_fs_path(Path::new("base/dir")).unwrap(), - Path::new("base/dir") - ); - assert_eq!( - repo_path("").to_fs_path(Path::new("")).unwrap(), - Path::new(".") - ); - assert_eq!( - repo_path("file").to_fs_path(Path::new("base/dir")).unwrap(), - Path::new("base/dir/file") - ); - assert_eq!( - repo_path("some/deep/dir/file") - .to_fs_path(Path::new("base/dir")) - .unwrap(), - Path::new("base/dir/some/deep/dir/file") - ); - assert_eq!( - repo_path("dir/file").to_fs_path(Path::new("")).unwrap(), - Path::new("dir/file") - ); - - // Current/parent dir component - assert!(repo_path(".").to_fs_path(Path::new("base")).is_err()); - assert!(repo_path("..").to_fs_path(Path::new("base")).is_err()); - assert!( - repo_path("dir/../file") - .to_fs_path(Path::new("base")) - .is_err() - ); - assert!(repo_path("./file").to_fs_path(Path::new("base")).is_err()); - assert!(repo_path("file/.").to_fs_path(Path::new("base")).is_err()); - assert!(repo_path("../file").to_fs_path(Path::new("base")).is_err()); - assert!(repo_path("file/..").to_fs_path(Path::new("base")).is_err()); - - // Empty component (which is invalid as a repo path) - assert!( - RepoPath::from_internal_string_unchecked("/") - .to_fs_path(Path::new("base")) - .is_err() - ); - assert_eq!( - // Iterator omits empty component after "/", which is fine so long - // as the returned path doesn't escape. - RepoPath::from_internal_string_unchecked("a/") - .to_fs_path(Path::new("base")) - .unwrap(), - Path::new("base/a") - ); - assert!( - RepoPath::from_internal_string_unchecked("/b") - .to_fs_path(Path::new("base")) - .is_err() - ); - assert!( - RepoPath::from_internal_string_unchecked("a//b") - .to_fs_path(Path::new("base")) - .is_err() - ); - - // Component containing slash (simulating Windows path separator) - assert!( - RepoPathComponent::new_unchecked("wind/ows") - .to_fs_name() - .is_err() - ); - assert!( - RepoPathComponent::new_unchecked("./file") - .to_fs_name() - .is_err() - ); - assert!( - RepoPathComponent::new_unchecked("file/.") - .to_fs_name() - .is_err() - ); - assert!(RepoPathComponent::new_unchecked("/").to_fs_name().is_err()); - - // Windows path separator and drive letter - if cfg!(windows) { - assert!( - repo_path(r#"wind\ows"#) - .to_fs_path(Path::new("base")) - .is_err() - ); - assert!( - repo_path(r#".\file"#) - .to_fs_path(Path::new("base")) - .is_err() - ); - assert!( - repo_path(r#"file\."#) - .to_fs_path(Path::new("base")) - .is_err() - ); - assert!( - repo_path(r#"c:/foo"#) - .to_fs_path(Path::new("base")) - .is_err() - ); - } - } - - #[test] - fn test_to_fs_path_unchecked() { - assert_eq!( - repo_path("").to_fs_path_unchecked(Path::new("base/dir")), - Path::new("base/dir") - ); - assert_eq!( - repo_path("").to_fs_path_unchecked(Path::new("")), - Path::new(".") - ); - assert_eq!( - repo_path("file").to_fs_path_unchecked(Path::new("base/dir")), - Path::new("base/dir/file") - ); - assert_eq!( - repo_path("some/deep/dir/file").to_fs_path_unchecked(Path::new("base/dir")), - Path::new("base/dir/some/deep/dir/file") - ); - assert_eq!( - repo_path("dir/file").to_fs_path_unchecked(Path::new("")), - Path::new("dir/file") - ); - } - - #[test] - fn parse_fs_path_wc_in_cwd() { - let temp_dir = new_temp_dir(); - let cwd_path = temp_dir.path().join("repo"); - let wc_path = &cwd_path; - - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "").as_deref(), - Ok(RepoPath::root()) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".").as_deref(), - Ok(RepoPath::root()) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "file").as_deref(), - Ok(repo_path("file")) - ); - // Both slash and the platform's separator are allowed - assert_eq!( - RepoPathBuf::parse_fs_path( - &cwd_path, - wc_path, - format!("dir{}file", std::path::MAIN_SEPARATOR) - ) - .as_deref(), - Ok(repo_path("dir/file")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "dir/file").as_deref(), - Ok(repo_path("dir/file")) - ); - assert_matches!( - RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".."), - Err(FsPathParseError { - source: RelativePathParseError::InvalidComponent { .. }, - .. - }) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo").as_deref(), - Ok(RepoPath::root()) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo/file").as_deref(), - Ok(repo_path("file")) - ); - // Input may be absolute path with ".." - assert_eq!( - RepoPathBuf::parse_fs_path( - &cwd_path, - &cwd_path, - cwd_path.join("../repo").to_str().unwrap() - ) - .as_deref(), - Ok(RepoPath::root()) - ); - } - - #[test] - fn parse_fs_path_wc_in_cwd_parent() { - let temp_dir = new_temp_dir(); - let cwd_path = temp_dir.path().join("dir"); - let wc_path = cwd_path.parent().unwrap().to_path_buf(); - - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "").as_deref(), - Ok(repo_path("dir")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ".").as_deref(), - Ok(repo_path("dir")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "file").as_deref(), - Ok(repo_path("dir/file")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "subdir/file").as_deref(), - Ok(repo_path("dir/subdir/file")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "..").as_deref(), - Ok(RepoPath::root()) - ); - assert_matches!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../.."), - Err(FsPathParseError { - source: RelativePathParseError::InvalidComponent { .. }, - .. - }) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../other-dir/file").as_deref(), - Ok(repo_path("other-dir/file")) - ); - } - - #[test] - fn parse_fs_path_wc_in_cwd_child() { - let temp_dir = new_temp_dir(); - let cwd_path = temp_dir.path().join("cwd"); - let wc_path = cwd_path.join("repo"); - - assert_matches!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ""), - Err(FsPathParseError { - source: RelativePathParseError::InvalidComponent { .. }, - .. - }) - ); - assert_matches!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "not-repo"), - Err(FsPathParseError { - source: RelativePathParseError::InvalidComponent { .. }, - .. - }) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo").as_deref(), - Ok(RepoPath::root()) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/file").as_deref(), - Ok(repo_path("file")) - ); - assert_eq!( - RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/dir/file").as_deref(), - Ok(repo_path("dir/file")) - ); - } - #[test] fn test_format_copied_path() { let ui = RepoPathUiConverter::Fs { @@ -1359,47 +274,4 @@ mod tests { "x/something/{ => something}/1to2.txt" ); } - - #[test] - fn test_split_common_prefix() { - assert_eq!( - repo_path("foo/bar").split_common_prefix(repo_path("foo/bar/baz")), - (repo_path("foo/bar"), repo_path("")) - ); - - assert_eq!( - repo_path("foo/bar/baz").split_common_prefix(repo_path("foo/bar")), - (repo_path("foo/bar"), repo_path("baz")) - ); - - assert_eq!( - repo_path("foo/bar/bing").split_common_prefix(repo_path("foo/bar/baz")), - (repo_path("foo/bar"), repo_path("bing")) - ); - - assert_eq!( - repo_path("no/common/prefix").split_common_prefix(repo_path("foo/bar/baz")), - (RepoPath::root(), repo_path("no/common/prefix")) - ); - - assert_eq!( - repo_path("same/path").split_common_prefix(repo_path("same/path")), - (repo_path("same/path"), RepoPath::root()) - ); - - assert_eq!( - RepoPath::root().split_common_prefix(repo_path("foo")), - (RepoPath::root(), RepoPath::root()) - ); - - assert_eq!( - RepoPath::root().split_common_prefix(RepoPath::root()), - (RepoPath::root(), RepoPath::root()) - ); - - assert_eq!( - repo_path("foo/bar").split_common_prefix(RepoPath::root()), - (RepoPath::root(), repo_path("foo/bar")) - ); - } } From 54d87dabe4808b32d5559169de4f87b57a66d551 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Tue, 7 Jul 2026 17:39:49 +0200 Subject: [PATCH 3/8] core: complete the documentation for `repo_path` and `file_util` As requested by Martin. --- lib/core/src/file_util.rs | 8 +++++++- lib/core/src/repo_path.rs | 25 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/core/src/file_util.rs b/lib/core/src/file_util.rs index 7def07eb565..1b877b54c30 100644 --- a/lib/core/src/file_util.rs +++ b/lib/core/src/file_util.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#![expect(missing_docs)] +//! Contains file utilities which work in a cross-platform way. use std::borrow::Cow; use std::ffi::OsString; @@ -37,14 +37,19 @@ pub use self::platform::check_symlink_support; pub use self::platform::symlink_dir; pub use self::platform::symlink_file; +/// An error which can occur when accessing paths. #[derive(Debug, Error)] #[error("Cannot access {path}")] pub struct PathError { + /// The path which is inaccessible. pub path: PathBuf, + /// The underlying error source. pub source: io::Error, } +/// An extension trait to `io::Result` to allow adding a path as context. pub trait IoResultExt { + /// Provide more context to the `io::Result`. fn context(self, path: impl AsRef) -> Result; } @@ -94,6 +99,7 @@ pub fn is_empty_dir(path: &Path) -> Result { } } +/// An error which occurs when we encounter a path which isn't UTF-8 encoded. #[derive(Debug, Error)] #[error(transparent)] pub struct BadPathEncoding(platform::BadOsStrEncoding); diff --git a/lib/core/src/repo_path.rs b/lib/core/src/repo_path.rs index 8d6c62e90b4..face177b57f 100644 --- a/lib/core/src/repo_path.rs +++ b/lib/core/src/repo_path.rs @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -#![allow(missing_docs)] +//! A [`RepoPath`] is a path relative to the repo root. It uses forward slashes +//! as directory separators regardless of platform. It is always valid UTF-8. use std::borrow::Borrow; use std::cmp::Ordering; @@ -388,6 +389,7 @@ impl RepoPath { result } + /// Returns true if this is a root path. pub fn is_root(&self) -> bool { self.value.is_empty() } @@ -424,14 +426,23 @@ impl RepoPath { Some((components.as_path(), basename)) } + /// Iterator over the path's components, with parents before children. + /// + /// For example, `RepoPath::from_internal_string("a/b/c")?.components()` + /// yields "a", "b", "c". pub fn components(&self) -> RepoPathComponentsIter<'_> { RepoPathComponentsIter { value: &self.value } } + /// Iterator over the path's ancestors, with children before parents. + /// + /// For example, `RepoPath::from_internal_string("a/b/c")?.ancestors()` + /// yiels "a/b/c", "a/b", "a", "". pub fn ancestors(&self) -> impl Iterator { std::iter::successors(Some(self), |path| path.parent()) } + /// Join the given `entry` on the Path returning a new `RepoPathBuf`. pub fn join(&self, entry: &RepoPathComponent) -> RepoPathBuf { let value = if self.value.is_empty() { entry.as_internal_str().to_owned() @@ -601,6 +612,7 @@ pub struct InvalidRepoPathError { #[derive(Clone, Debug, Eq, Error, PartialEq)] #[error(r#"Invalid path component "{component}""#)] pub struct InvalidRepoPathComponentError { + /// The invalid component. pub component: Box, } @@ -614,17 +626,26 @@ impl InvalidRepoPathComponentError { } } +/// An error which occurs during relative path parsing. #[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum RelativePathParseError { + /// An invalid component was seen. #[error(r#"Invalid component "{component}" in repo-relative path "{path}""#)] InvalidComponent { + /// The invalid component. component: Box, + /// The path it was a component of. path: Box, }, + /// The path was not UTF-8. #[error(r#"Not valid UTF-8 path "{path}""#)] - InvalidUtf8 { path: Box }, + InvalidUtf8 { + /// The path which did not contain UTF-8 characters. + path: Box, + }, } +/// An error which occurs when we're parsing paths. #[derive(Clone, Debug, Eq, Error, PartialEq)] #[error(r#"Path "{input}" is not in the repo "{base}""#)] pub struct FsPathParseError { From b98a0b2461fefc15f639cc9ae9e48dec86443878 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Tue, 9 Jun 2026 23:28:19 +0200 Subject: [PATCH 4/8] core: Move the `Matcher` trait This is in preparation of moving `Backend`, `Index` and `Store`. I've also added the `Nothing/EverythingMatcher` even though it's a clear layering violation but I've granted myself an exception for that since they're quite basic. Its a simple move since it only depends on `RepoPathBuf` which already is in the new core crate. Part of #6284 --- lib/core/src/lib.rs | 1 + lib/core/src/matchers.rs | 145 +++++++++++++++++++++++++++++++++++++++ lib/src/matchers.rs | 112 ++---------------------------- 3 files changed, 153 insertions(+), 105 deletions(-) create mode 100644 lib/core/src/matchers.rs diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index 603dc546dba..acfef9f19ea 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -30,6 +30,7 @@ extern crate self as jj_core; pub mod content_hash; pub mod file_util; +pub mod matchers; pub mod repo_path; #[cfg(test)] diff --git a/lib/core/src/matchers.rs b/lib/core/src/matchers.rs new file mode 100644 index 00000000000..62c522b68fc --- /dev/null +++ b/lib/core/src/matchers.rs @@ -0,0 +1,145 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains the [`Matcher`] trait which is used for Filesystem traversal. + +use std::collections::HashSet; +use std::fmt::Debug; + +use crate::repo_path::RepoPath; +use crate::repo_path::RepoPathComponentBuf; + +/// Describes how to traverse a Filesystem or Tree. +#[derive(PartialEq, Eq, Debug)] +pub enum Visit { + /// Everything in the directory is *guaranteed* to match, no need to check + /// descendants + AllRecursively, + /// Visit only the specified directories or files. + Specific { + /// Visit these specific directories. + dirs: VisitDirs, + /// Visit these specific files. + files: VisitFiles, + }, + /// Nothing in the directory or its subdirectories will match. + /// + /// This is the same as `Specific` with no directories or files. Use + /// `Visit::set()` to get create an instance that's `Specific` or + /// `Nothing` depending on the values at runtime. + Nothing, +} + +impl Visit { + /// All entries in the directory need to be visited, but they are not + /// guaranteed to match. + pub const SOME: Self = Self::Specific { + dirs: VisitDirs::All, + files: VisitFiles::All, + }; + + /// Visit these sets of `dirs` and `files`. + pub fn sets(dirs: HashSet, files: HashSet) -> Self { + if dirs.is_empty() && files.is_empty() { + Self::Nothing + } else { + Self::Specific { + dirs: VisitDirs::Set(dirs), + files: VisitFiles::Set(files), + } + } + } + + /// Returns true if nothing is matched. + pub fn is_nothing(&self) -> bool { + *self == Self::Nothing + } +} + +/// Visit all or some specific directories. +#[derive(PartialEq, Eq, Debug)] +pub enum VisitDirs { + /// Visit all possible directories. + All, + /// Visit the specified set of directories. + Set(HashSet), +} + +/// Visit all or some specific files. +#[derive(PartialEq, Eq, Debug)] +pub enum VisitFiles { + /// Visit all possible files. + All, + /// Visit the specified set of files. + Set(HashSet), +} + +/// `Matcher`'s are used to specify how the snapshotting path traverses +/// directories and files. +pub trait Matcher: Debug + Send + Sync { + /// Returns true if the `file` matches the traversal. + fn matches(&self, file: &RepoPath) -> bool; + /// Returns a `Visit` which specifies how further traversal should commence. + fn visit(&self, dir: &RepoPath) -> Visit; +} + +impl Matcher for &T { + fn matches(&self, file: &RepoPath) -> bool { + ::matches(self, file) + } + + fn visit(&self, dir: &RepoPath) -> Visit { + ::visit(self, dir) + } +} + +impl Matcher for Box { + fn matches(&self, file: &RepoPath) -> bool { + ::matches(self, file) + } + + fn visit(&self, dir: &RepoPath) -> Visit { + ::visit(self, dir) + } +} + +/// Match no Path and don't recursively visit any subtree. +// This is a layering violation, since jj-core should just contain traits. +#[derive(PartialEq, Eq, Debug)] +pub struct NothingMatcher; + +impl Matcher for NothingMatcher { + fn matches(&self, _file: &RepoPath) -> bool { + false + } + + fn visit(&self, _dir: &RepoPath) -> Visit { + Visit::Nothing + } +} + +/// Match every Path and recursively visit any subtree. +// This is a layering violation, since jj-core should just contain traits. +#[derive(PartialEq, Eq, Debug)] +pub struct EverythingMatcher; + +impl Matcher for EverythingMatcher { + fn matches(&self, _file: &RepoPath) -> bool { + true + } + + fn visit(&self, _dir: &RepoPath) -> Visit { + Visit::AllRecursively + } +} diff --git a/lib/src/matchers.rs b/lib/src/matchers.rs index 10cc901064a..e2fa830d87c 100644 --- a/lib/src/matchers.rs +++ b/lib/src/matchers.rs @@ -19,116 +19,17 @@ use std::fmt::Debug; use globset::Glob; use itertools::Itertools as _; +pub use jj_core::matchers::EverythingMatcher; +pub use jj_core::matchers::Matcher; +pub use jj_core::matchers::NothingMatcher; +pub use jj_core::matchers::Visit; +pub use jj_core::matchers::VisitDirs; +pub use jj_core::matchers::VisitFiles; use tracing::instrument; use crate::repo_path::RepoPath; -use crate::repo_path::RepoPathComponentBuf; use crate::repo_path::RepoPathTree; -#[derive(PartialEq, Eq, Debug)] -pub enum Visit { - /// Everything in the directory is *guaranteed* to match, no need to check - /// descendants - AllRecursively, - Specific { - dirs: VisitDirs, - files: VisitFiles, - }, - /// Nothing in the directory or its subdirectories will match. - /// - /// This is the same as `Specific` with no directories or files. Use - /// `Visit::set()` to get create an instance that's `Specific` or - /// `Nothing` depending on the values at runtime. - Nothing, -} - -impl Visit { - /// All entries in the directory need to be visited, but they are not - /// guaranteed to match. - const SOME: Self = Self::Specific { - dirs: VisitDirs::All, - files: VisitFiles::All, - }; - - fn sets(dirs: HashSet, files: HashSet) -> Self { - if dirs.is_empty() && files.is_empty() { - Self::Nothing - } else { - Self::Specific { - dirs: VisitDirs::Set(dirs), - files: VisitFiles::Set(files), - } - } - } - - pub fn is_nothing(&self) -> bool { - *self == Self::Nothing - } -} - -#[derive(PartialEq, Eq, Debug)] -pub enum VisitDirs { - All, - Set(HashSet), -} - -#[derive(PartialEq, Eq, Debug)] -pub enum VisitFiles { - All, - Set(HashSet), -} - -pub trait Matcher: Debug + Send + Sync { - fn matches(&self, file: &RepoPath) -> bool; - fn visit(&self, dir: &RepoPath) -> Visit; -} - -impl Matcher for &T { - fn matches(&self, file: &RepoPath) -> bool { - ::matches(self, file) - } - - fn visit(&self, dir: &RepoPath) -> Visit { - ::visit(self, dir) - } -} - -impl Matcher for Box { - fn matches(&self, file: &RepoPath) -> bool { - ::matches(self, file) - } - - fn visit(&self, dir: &RepoPath) -> Visit { - ::visit(self, dir) - } -} - -#[derive(PartialEq, Eq, Debug)] -pub struct NothingMatcher; - -impl Matcher for NothingMatcher { - fn matches(&self, _file: &RepoPath) -> bool { - false - } - - fn visit(&self, _dir: &RepoPath) -> Visit { - Visit::Nothing - } -} - -#[derive(PartialEq, Eq, Debug)] -pub struct EverythingMatcher; - -impl Matcher for EverythingMatcher { - fn matches(&self, _file: &RepoPath) -> bool { - true - } - - fn visit(&self, _dir: &RepoPath) -> Visit { - Visit::AllRecursively - } -} - #[derive(PartialEq, Eq, Debug)] pub struct FilesMatcher { tree: RepoPathTree, @@ -527,6 +428,7 @@ mod tests { use super::*; use crate::fileset::parse_file_glob; + use crate::repo_path::RepoPathComponentBuf; fn repo_path(value: &str) -> &RepoPath { RepoPath::from_internal_string(value).unwrap() From 0ce1b3c4874383ad41df752c17f7ba51c34636a8 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Sat, 6 Jun 2026 02:35:43 +0200 Subject: [PATCH 5/8] core: Lower `SigningBackend` and its related utilities This moves the first interface into the new core library. This also moves the `Signer` struct by dropping the `Config` dependency, to preserve the `jj-lib` API it is imported as `CoreSigner`. Part of #6284 --- Cargo.lock | 1 + lib/core/Cargo.toml | 3 +- lib/core/src/backend.rs | 26 +++ lib/core/src/content_hash.rs | 31 +++ lib/{ => core}/src/hex_util.rs | 0 lib/core/src/lib.rs | 4 + lib/core/src/object_id.rs | 358 +++++++++++++++++++++++++++++++++ lib/core/src/signing.rs | 220 ++++++++++++++++++++ lib/src/backend.rs | 6 +- lib/src/content_hash.rs | 45 ----- lib/src/lib.rs | 2 +- lib/src/object_id.rs | 328 +----------------------------- lib/src/signing.rs | 176 ++-------------- 13 files changed, 665 insertions(+), 535 deletions(-) create mode 100644 lib/core/src/backend.rs rename lib/{ => core}/src/hex_util.rs (100%) create mode 100644 lib/core/src/object_id.rs create mode 100644 lib/core/src/signing.rs diff --git a/Cargo.lock b/Cargo.lock index 687ac0c6641..5f47d9e98ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2647,6 +2647,7 @@ dependencies = [ "etcetera", "eyre", "futures 0.3.33", + "insta", "itertools 0.15.0", "jj-core-proc-macros", "pollster", diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml index d15ce15196a..b42d9d59341 100644 --- a/lib/core/Cargo.toml +++ b/lib/core/Cargo.toml @@ -32,8 +32,9 @@ tracing = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } -test-case = { workspace = true } eyre = { workspace = true } +insta = { workspace = true } +test-case = { workspace = true } [target.'cfg(windows)'.dependencies] same-file = { workspace = true } diff --git a/lib/core/src/backend.rs b/lib/core/src/backend.rs new file mode 100644 index 00000000000..ccc3b158dcd --- /dev/null +++ b/lib/core/src/backend.rs @@ -0,0 +1,26 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains a basic shim for some Backend types such as [`ChangeId`] and +//! [`CommitId`]. +// TODO: move the `Backend` trait into this. + +use crate::object_id::ObjectId as _; +use crate::object_id::id_type; + +id_type!( + /// Identifier for a [`Commit`] based on its content. When a commit is + /// rewritten, its `CommitId` changes. + pub CommitId { hex() } +); diff --git a/lib/core/src/content_hash.rs b/lib/core/src/content_hash.rs index 92a67464e9d..932bab2eaf0 100644 --- a/lib/core/src/content_hash.rs +++ b/lib/core/src/content_hash.rs @@ -190,6 +190,7 @@ mod tests { use std::collections::HashMap; use super::*; + use crate::hex_util; #[test] fn test_string_sanity() { @@ -263,6 +264,36 @@ mod tests { assert_ne!(hash(&42i32), hash(&[42i32][..])); } + #[test] + fn test_consistent_hashing() { + #[derive(ContentHash)] + struct Foo { + x: Vec>, + y: i64, + } + let foo_hash = hex_util::encode_hex(&hash(&Foo { + x: vec![None, Some(42)], + y: 17, + })); + insta::assert_snapshot!( + foo_hash, + @"e33c423b4b774b1353c414e0f9ef108822fde2fd5113fcd53bf7bd9e74e3206690b96af96373f268ed95dd020c7cbe171c7b7a6947fcaf5703ff6c8e208cefd4" + ); + + // Try again with an equivalent generic struct deriving ContentHash. + #[derive(ContentHash)] + struct GenericFoo { + x: X, + y: Y, + } + assert_eq!( + hex_util::encode_hex(&hash(&GenericFoo { + x: vec![None, Some(42)], + y: 17i64 + })), + foo_hash + ); + } // Test that the derived version of `ContentHash` matches the that's // manually implemented for `std::Option`. #[test] diff --git a/lib/src/hex_util.rs b/lib/core/src/hex_util.rs similarity index 100% rename from lib/src/hex_util.rs rename to lib/core/src/hex_util.rs diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index acfef9f19ea..c1a2925f613 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -29,9 +29,13 @@ extern crate self as jj_core; #[macro_use] pub mod content_hash; +pub mod backend; pub mod file_util; +pub mod hex_util; pub mod matchers; +pub mod object_id; pub mod repo_path; +pub mod signing; #[cfg(test)] mod tests { diff --git a/lib/core/src/object_id.rs b/lib/core/src/object_id.rs new file mode 100644 index 00000000000..0a7da2585fa --- /dev/null +++ b/lib/core/src/object_id.rs @@ -0,0 +1,358 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Utilities for binary Objects, such as a macro for a [`ObjectId`] newtype. +//! See [`id_type!`]. + +use std::fmt; +use std::fmt::Debug; + +use crate::hex_util; + +/// Describes the common functionality of a binary ID type. +pub trait ObjectId { + /// Get actual Name of underlying type. + // this strips the trailing `Id`. + fn object_type(&self) -> String; + + /// Get access to the underlying bytes. + fn as_bytes(&self) -> &[u8]; + + /// Get access to another copy of the underlying bytes. + fn to_bytes(&self) -> Vec; + + /// Convert the underlying bytes into a hex-formatted string. + fn hex(&self) -> String; +} + +/// Defines a new struct type with visibility `vis` and name `ident` containing +/// a single `Vec` used to store an identifier (typically the output of a +/// hash function) as bytes. Types defined using this macro automatically +/// implement the `ObjectId` and `ContentHash` traits. +/// Documentation comments written inside the macro definition will be captured +/// and associated with the type defined by the macro. +/// +/// Example: +/// ```no_run +/// # use jj_core::id_type; +/// use jj_core::object_id::ObjectId; +/// id_type!( +/// /// My favorite id type. +/// pub MyId { hex() } +/// ); +/// ``` +#[macro_export] +macro_rules! id_type { + ( $(#[$attr:meta])* + $vis:vis $name:ident { $hex_method:ident() } + ) => { + $(#[$attr])* + #[derive($crate::content_hash::ContentHash, PartialEq, Eq, PartialOrd, Ord, Clone, Hash)] + $vis struct $name(Vec); + $crate::object_id::impl_id_type!($name, $hex_method); + }; +} + +/// Defines the required methods needed for a [`id_type`] which is a newtype +/// over a `Vec`. +#[macro_export] +macro_rules! impl_id_type { + ($name:ident, $hex_method:ident) => { + #[allow(dead_code)] + impl $name { + /// Creates a new instance of this id type from the given bytes. + pub fn new(value: Vec) -> Self { + Self(value) + } + + /// Creates a new instance of this id type from the given byte slice. + pub fn from_bytes(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } + + /// Parses the given hex string into an ObjectId. + /// + /// The given string must be valid. A static str is required to + /// prevent API misuse. + pub fn from_hex(hex: &'static str) -> Self { + Self::try_from_hex(hex).unwrap() + } + + /// Parses the given hex string into an ObjectId. + pub fn try_from_hex(hex: impl AsRef<[u8]>) -> Option { + $crate::hex_util::decode_hex(hex).map(Self) + } + } + + impl std::fmt::Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + // TODO: should we use $hex_method here? + f.debug_tuple(stringify!($name)).field(&self.hex()).finish() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.pad(&self.$hex_method()) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if serializer.is_human_readable() { + self.$hex_method().serialize(serializer) + } else { + self.as_bytes().serialize(serializer) + } + } + } + + impl $crate::object_id::ObjectId for $name { + fn object_type(&self) -> String { + stringify!($name) + .strip_suffix("Id") + .unwrap() + .to_ascii_lowercase() + .to_string() + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } + + fn to_bytes(&self) -> Vec { + self.0.clone() + } + + fn hex(&self) -> String { + $crate::hex_util::encode_hex(&self.0) + } + } + }; +} + +pub use id_type; +pub use impl_id_type; + +/// An identifier prefix (typically from a type implementing the [`ObjectId`] +/// trait) with facilities for converting between bytes and a hex string. +#[derive(Clone, PartialEq, Eq)] +pub struct HexPrefix { + // For odd-length prefixes, the lower 4 bits of the last byte are + // zero-filled (e.g. the prefix "abc" is stored in two bytes as "abc0"). + min_prefix_bytes: Vec, + has_odd_byte: bool, +} + +impl HexPrefix { + /// Returns a new `HexPrefix` or `None` if `prefix` cannot be decoded from + /// hex to bytes. + pub fn try_from_hex(prefix: impl AsRef<[u8]>) -> Option { + let (min_prefix_bytes, has_odd_byte) = hex_util::decode_hex_prefix(prefix)?; + Some(Self { + min_prefix_bytes, + has_odd_byte, + }) + } + + /// Returns a new `HexPrefix` or `None` if `prefix` cannot be decoded from + /// "reverse" hex to bytes. + pub fn try_from_reverse_hex(prefix: impl AsRef<[u8]>) -> Option { + let (min_prefix_bytes, has_odd_byte) = hex_util::decode_reverse_hex_prefix(prefix)?; + Some(Self { + min_prefix_bytes, + has_odd_byte, + }) + } + + /// Create a new `HexPrefix` from the given bytes. + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { + min_prefix_bytes: bytes.to_owned(), + has_odd_byte: false, + } + } + + /// Returns a new `HexPrefix` representing the given `id`. + pub fn from_id(id: &T) -> Self { + Self::from_bytes(id.as_bytes()) + } + + /// Returns string representation of this prefix using hex digits. + pub fn hex(&self) -> String { + let mut hex_string = hex_util::encode_hex(&self.min_prefix_bytes); + if self.has_odd_byte { + hex_string.pop().unwrap(); + } + hex_string + } + + /// Returns string representation of this prefix using `z-k` "digits". + pub fn reverse_hex(&self) -> String { + let mut hex_string = hex_util::encode_reverse_hex(&self.min_prefix_bytes); + if self.has_odd_byte { + hex_string.pop().unwrap(); + } + hex_string + } + + /// Minimum bytes that would match this prefix. (e.g. "abc0" for "abc") + /// + /// Use this to partition a sorted slice, and test `matches(id)` from there. + pub fn min_prefix_bytes(&self) -> &[u8] { + &self.min_prefix_bytes + } + + /// Returns the bytes representation if this prefix can be a full id. + pub fn as_full_bytes(&self) -> Option<&[u8]> { + (!self.has_odd_byte).then_some(&self.min_prefix_bytes) + } + + fn split_odd_byte(&self) -> (Option, &[u8]) { + if self.has_odd_byte { + let (&odd, prefix) = self.min_prefix_bytes.split_last().unwrap(); + (Some(odd), prefix) + } else { + (None, &self.min_prefix_bytes) + } + } + + /// Returns whether the stored prefix matches the prefix of `id`. + pub fn matches(&self, id: &Q) -> bool { + let id_bytes = id.as_bytes(); + let (maybe_odd, prefix) = self.split_odd_byte(); + if id_bytes.starts_with(prefix) { + if let Some(odd) = maybe_odd { + matches!(id_bytes.get(prefix.len()), Some(v) if v & 0xf0 == odd) + } else { + true + } + } else { + false + } + } +} + +impl Debug for HexPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + f.debug_tuple("HexPrefix").field(&self.hex()).finish() + } +} + +/// The result of a prefix search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrefixResolution { + /// Nothing matched. + NoMatch, + /// There was a single match, containing `T`. + SingleMatch(T), + /// There were multiple matches and thus ambiguous. + AmbiguousMatch, +} + +impl PrefixResolution { + /// Apply `f` on this `PrefixResolution` returning the new result. + pub fn map(self, f: impl FnOnce(T) -> U) -> PrefixResolution { + match self { + Self::NoMatch => PrefixResolution::NoMatch, + Self::SingleMatch(x) => PrefixResolution::SingleMatch(f(x)), + Self::AmbiguousMatch => PrefixResolution::AmbiguousMatch, + } + } + + /// Apply `f` on this `PrefixResolution` returning the new result if there's + /// a single match. + pub fn filter_map(self, f: impl FnOnce(T) -> Option) -> PrefixResolution { + match self { + Self::NoMatch => PrefixResolution::NoMatch, + Self::SingleMatch(x) => match f(x) { + None => PrefixResolution::NoMatch, + Some(y) => PrefixResolution::SingleMatch(y), + }, + Self::AmbiguousMatch => PrefixResolution::AmbiguousMatch, + } + } +} + +impl PrefixResolution { + /// Combine the results of two different `PrefixResolution`'s. + pub fn plus(&self, other: &Self) -> Self { + match (self, other) { + (Self::NoMatch, other) => other.clone(), + (local, Self::NoMatch) => local.clone(), + (Self::AmbiguousMatch, _) => Self::AmbiguousMatch, + (_, Self::AmbiguousMatch) => Self::AmbiguousMatch, + (Self::SingleMatch(_), Self::SingleMatch(_)) => Self::AmbiguousMatch, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::ChangeId; + use crate::backend::CommitId; + + #[test] + fn test_display_object_id() { + let commit_id = CommitId::from_hex("deadbeef0123"); + assert_eq!(format!("{commit_id}"), "deadbeef0123"); + assert_eq!(format!("{commit_id:.6}"), "deadbe"); + + let change_id = ChangeId::from_hex("deadbeef0123"); + assert_eq!(format!("{change_id}"), "mlpmollkzyxw"); + assert_eq!(format!("{change_id:.6}"), "mlpmol"); + } + + #[test] + fn test_hex_prefix_prefixes() { + let prefix = HexPrefix::try_from_hex("").unwrap(); + assert_eq!(prefix.min_prefix_bytes(), b""); + + let prefix = HexPrefix::try_from_hex("1").unwrap(); + assert_eq!(prefix.min_prefix_bytes(), b"\x10"); + + let prefix = HexPrefix::try_from_hex("12").unwrap(); + assert_eq!(prefix.min_prefix_bytes(), b"\x12"); + + let prefix = HexPrefix::try_from_hex("123").unwrap(); + assert_eq!(prefix.min_prefix_bytes(), b"\x12\x30"); + + let bad_prefix = HexPrefix::try_from_hex("0x123"); + assert_eq!(bad_prefix, None); + + let bad_prefix = HexPrefix::try_from_hex("foobar"); + assert_eq!(bad_prefix, None); + } + + #[test] + fn test_hex_prefix_matches() { + let id = CommitId::from_hex("1234"); + + assert!(HexPrefix::try_from_hex("").unwrap().matches(&id)); + assert!(HexPrefix::try_from_hex("1").unwrap().matches(&id)); + assert!(HexPrefix::try_from_hex("12").unwrap().matches(&id)); + assert!(HexPrefix::try_from_hex("123").unwrap().matches(&id)); + assert!(HexPrefix::try_from_hex("1234").unwrap().matches(&id)); + assert!(!HexPrefix::try_from_hex("12345").unwrap().matches(&id)); + + assert!(!HexPrefix::try_from_hex("a").unwrap().matches(&id)); + assert!(!HexPrefix::try_from_hex("1a").unwrap().matches(&id)); + assert!(!HexPrefix::try_from_hex("12a").unwrap().matches(&id)); + assert!(!HexPrefix::try_from_hex("123a").unwrap().matches(&id)); + } +} diff --git a/lib/core/src/signing.rs b/lib/core/src/signing.rs new file mode 100644 index 00000000000..8ba8fa76987 --- /dev/null +++ b/lib/core/src/signing.rs @@ -0,0 +1,220 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains the [`SigningBackend`] a trait required for various signing +//! interactions. + +use std::fmt::Debug; +use std::fmt::Display; +use std::num::NonZeroUsize; +use std::sync::Mutex; + +use clru::CLruCache; +use thiserror::Error; + +use crate::backend::CommitId; + +// TODO: This is a duplication of `jj_lib::store::COMMIT_CACHE_CAPACITY`. Use +// the respective constant when we lower `Store`. +const SIGN_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(100).unwrap(); + +/// A status of the signature, part of the [Verification] type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigStatus { + /// Valid signature that matches the data. + Good, + /// Valid signature that could not be verified (e.g. due to an unknown key). + Unknown, + /// Valid signature that does not match the signed data. + Bad, +} + +impl Display for SigStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Good => "good", + Self::Unknown => "unknown", + Self::Bad => "bad", + }; + write!(f, "{s}") + } +} + +/// The result of a signature verification. +/// Key and display are optional additional info that backends can or can not +/// provide to add additional information for the templater to potentially show. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verification { + /// The status of the signature. + pub status: SigStatus, + /// The key id representation, if available. For GPG, this will be the key + /// fingerprint. + pub key: Option, + /// A display string, if available. For GPG, this will be formatted primary + /// user ID. + pub display: Option, +} + +impl Verification { + /// A shortcut to create an `Unknown` verification with no additional + /// metadata. + pub fn unknown() -> Self { + Self { + status: SigStatus::Unknown, + key: None, + display: None, + } + } + + /// Create a new verification + pub fn new(status: SigStatus, key: Option, display: Option) -> Self { + Self { + status, + key, + display, + } + } +} + +/// Wraps low-level signing backends and adds caching, similar to `Store`. +#[derive(Debug)] +pub struct Signer { + /// The backend that is used for signing commits. + /// Optional because signing might not be configured. + main_backend: Option>, + /// All known backends without the main one - used for verification. + /// Main backend is also used for verification, but it's not in this list + /// for ownership reasons. + backends: Vec>, + cache: Mutex>, +} + +impl Signer { + /// Creates a signer with the given backends. + pub fn new( + main_backend: Option>, + other_backends: Vec>, + ) -> Self { + Self { + main_backend, + backends: other_backends, + cache: Mutex::new(CLruCache::new(SIGN_CACHE_CAPACITY)), + } + } + + /// Checks if the signer can sign, i.e. if a main backend is configured. + pub fn can_sign(&self) -> bool { + self.main_backend.is_some() + } + + /// This is just a pass-through to the main backend that unconditionally + /// creates a signature. + pub fn sign(&self, data: &[u8], key: Option<&str>) -> SignResult> { + self.main_backend + .as_ref() + .expect("tried to sign without checking can_sign first") + .sign(data, key) + } + + /// Looks for backend that can verify the signature and returns the result + /// of its verification. + pub fn verify( + &self, + commit_id: &CommitId, + data: &[u8], + signature: &[u8], + ) -> SignResult { + let cached = self.cache.lock().unwrap().get(commit_id).cloned(); + if let Some(check) = cached { + return Ok(check); + } + + let verification = self + .main_backend + .iter() + .chain(self.backends.iter()) + .filter(|b| b.can_read(signature)) + // skip unknown and invalid sigs to allow other backends that can read to try + // for example, we might have gpg and sq, both of which could read a PGP signature + .find_map(|backend| match backend.verify(data, signature) { + Ok(check) if check.status == SigStatus::Unknown => None, + Err(SignError::InvalidSignatureFormat) => None, + e => Some(e), + }) + .transpose()?; + + if let Some(verification) = verification { + // a key might get imported before next call?. + // realistically this is unlikely, but technically + // it's correct to not cache unknowns here + if verification.status != SigStatus::Unknown { + self.cache + .lock() + .unwrap() + .put(commit_id.clone(), verification.clone()); + } + Ok(verification) + } else { + // now here it's correct to cache unknowns, as we don't + // have a backend that knows how to handle this signature + // + // not sure about how much of an optimization this is + self.cache + .lock() + .unwrap() + .put(commit_id.clone(), Verification::unknown()); + Ok(Verification::unknown()) + } + } +} + +/// The backend for signing and verifying cryptographic signatures. +/// +/// This allows using different signers, such as GPG or SSH, or different +/// versions of them. +pub trait SigningBackend: Debug + Send + Sync { + /// Name of the backend, used in the config and for display. + fn name(&self) -> &str; + + /// Check if the signature can be read and verified by this backend. + /// + /// Should check the signature format, usually just looks at the prefix. + fn can_read(&self, signature: &[u8]) -> bool; + + /// Create a signature for arbitrary data. + /// + /// The `key` parameter is what `jj sign` receives as key argument, or what + /// is configured in the `signing.key` config. + fn sign(&self, data: &[u8], key: Option<&str>) -> SignResult>; + + /// Verify a signature. Should be reflexive with `sign`: + /// ```rust,ignore + /// verify(data, sign(data)?)?.status == SigStatus::Good + /// ``` + fn verify(&self, data: &[u8], signature: &[u8]) -> SignResult; +} + +/// An error type for the signing/verifying operations +#[derive(Debug, Error)] +pub enum SignError { + /// The verification failed because the signature *format* was invalid. + #[error("Invalid signature")] + InvalidSignatureFormat, + /// A generic error from the backend impl. + #[error("Signing error")] + Backend(#[source] Box), +} + +/// A result type for the signing/verifying operations +pub type SignResult = Result; diff --git a/lib/src/backend.rs b/lib/src/backend.rs index 05ccd280c3c..f3b84bb2362 100644 --- a/lib/src/backend.rs +++ b/lib/src/backend.rs @@ -25,6 +25,7 @@ use async_trait::async_trait; use chrono::TimeZone as _; use futures::AsyncRead; use futures::stream::BoxStream; +pub use jj_core::backend::CommitId; use thiserror::Error; use crate::content_hash::ContentHash; @@ -39,11 +40,6 @@ use crate::repo_path::RepoPathComponent; use crate::repo_path::RepoPathComponentBuf; use crate::signing::SignResult; -id_type!( - /// Identifier for a [`Commit`] based on its content. When a commit is - /// rewritten, its `CommitId` changes. - pub CommitId { hex() } -); id_type!( /// Stable identifier for a [`Commit`]. Unlike the `CommitId`, the `ChangeId` /// follows the commit and is not updated when the commit is rewritten. diff --git a/lib/src/content_hash.rs b/lib/src/content_hash.rs index 6b3e7fee66d..f6d764a619e 100644 --- a/lib/src/content_hash.rs +++ b/lib/src/content_hash.rs @@ -5,48 +5,3 @@ pub use digest::Update as DigestUpdate; pub use jj_core::content_hash::ContentHash; pub use jj_core::content_hash::blake2b_hash; - -#[cfg(test)] -mod test { - - use blake2::Blake2b512; - - use super::*; - use crate::hex_util; - - // TODO: move this over when we lower `hex_util.rs` - #[test] - fn test_consistent_hashing() { - #[derive(ContentHash)] - struct Foo { - x: Vec>, - y: i64, - } - let foo_hash = hex_util::encode_hex(&hash(&Foo { - x: vec![None, Some(42)], - y: 17, - })); - insta::assert_snapshot!( - foo_hash, - @"e33c423b4b774b1353c414e0f9ef108822fde2fd5113fcd53bf7bd9e74e3206690b96af96373f268ed95dd020c7cbe171c7b7a6947fcaf5703ff6c8e208cefd4" - ); - - // Try again with an equivalent generic struct deriving ContentHash. - #[derive(ContentHash)] - struct GenericFoo { - x: X, - y: Y, - } - assert_eq!( - hex_util::encode_hex(&hash(&GenericFoo { - x: vec![None, Some(42)], - y: 17i64 - })), - foo_hash - ); - } - - fn hash(x: &(impl ContentHash + ?Sized)) -> digest::Output { - blake2b_hash(x) - } -} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 60517104774..e248c6e8f17 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -67,7 +67,7 @@ pub mod gitignore; pub mod gpg_signing; pub mod graph; pub mod graph_dominators; -pub mod hex_util; +pub use jj_core::hex_util; pub mod id_prefix; pub mod index; pub mod iter_util; diff --git a/lib/src/object_id.rs b/lib/src/object_id.rs index 24e513e257b..b1a6fb23eb2 100644 --- a/lib/src/object_id.rs +++ b/lib/src/object_id.rs @@ -12,323 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -#![expect(missing_docs)] - -use std::fmt; -use std::fmt::Debug; - -use crate::hex_util; - -pub trait ObjectId { - fn object_type(&self) -> String; - fn as_bytes(&self) -> &[u8]; - fn to_bytes(&self) -> Vec; - fn hex(&self) -> String; -} - -// Defines a new struct type with visibility `vis` and name `ident` containing -// a single Vec used to store an identifier (typically the output of a hash -// function) as bytes. Types defined using this macro automatically implement -// the `ObjectId` and `ContentHash` traits. -// Documentation comments written inside the macro definition will be captured -// and associated with the type defined by the macro. -// -// Example: -// ```no_run -// id_type!( -// /// My favorite id type. -// pub MyId { hex() } -// ); -// ``` -macro_rules! id_type { - ( $(#[$attr:meta])* - $vis:vis $name:ident { $hex_method:ident() } - ) => { - $(#[$attr])* - #[derive($crate::content_hash::ContentHash, PartialEq, Eq, PartialOrd, Ord, Clone, Hash)] - $vis struct $name(Vec); - $crate::object_id::impl_id_type!($name, $hex_method); - }; -} - -macro_rules! impl_id_type { - ($name:ident, $hex_method:ident) => { - #[allow(dead_code)] - impl $name { - /// Creates a new instance of this id type from the given bytes. - pub fn new(value: Vec) -> Self { - Self(value) - } - - /// Creates a new instance of this id type from the given byte slice. - pub fn from_bytes(bytes: &[u8]) -> Self { - Self(bytes.to_vec()) - } - - /// Parses the given hex string into an ObjectId. - /// - /// The given string must be valid. A static str is required to - /// prevent API misuse. - pub fn from_hex(hex: &'static str) -> Self { - Self::try_from_hex(hex).unwrap() - } - - /// Parses the given hex string into an ObjectId. - pub fn try_from_hex(hex: impl AsRef<[u8]>) -> Option { - $crate::hex_util::decode_hex(hex).map(Self) - } - } - - impl std::fmt::Debug for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - // TODO: should we use $hex_method here? - f.debug_tuple(stringify!($name)).field(&self.hex()).finish() - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - f.pad(&self.$hex_method()) - } - } - - impl serde::Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - if serializer.is_human_readable() { - self.$hex_method().serialize(serializer) - } else { - self.as_bytes().serialize(serializer) - } - } - } - - impl crate::object_id::ObjectId for $name { - fn object_type(&self) -> String { - stringify!($name) - .strip_suffix("Id") - .unwrap() - .to_ascii_lowercase() - .to_string() - } - - fn as_bytes(&self) -> &[u8] { - &self.0 - } - - fn to_bytes(&self) -> Vec { - self.0.clone() - } - - fn hex(&self) -> String { - $crate::hex_util::encode_hex(&self.0) - } - } - }; -} - -pub(crate) use id_type; -pub(crate) use impl_id_type; - -/// An identifier prefix (typically from a type implementing the [`ObjectId`] -/// trait) with facilities for converting between bytes and a hex string. -#[derive(Clone, PartialEq, Eq)] -pub struct HexPrefix { - // For odd-length prefixes, the lower 4 bits of the last byte are - // zero-filled (e.g. the prefix "abc" is stored in two bytes as "abc0"). - min_prefix_bytes: Vec, - has_odd_byte: bool, -} - -impl HexPrefix { - /// Returns a new `HexPrefix` or `None` if `prefix` cannot be decoded from - /// hex to bytes. - pub fn try_from_hex(prefix: impl AsRef<[u8]>) -> Option { - let (min_prefix_bytes, has_odd_byte) = hex_util::decode_hex_prefix(prefix)?; - Some(Self { - min_prefix_bytes, - has_odd_byte, - }) - } - - /// Returns a new `HexPrefix` or `None` if `prefix` cannot be decoded from - /// "reverse" hex to bytes. - pub fn try_from_reverse_hex(prefix: impl AsRef<[u8]>) -> Option { - let (min_prefix_bytes, has_odd_byte) = hex_util::decode_reverse_hex_prefix(prefix)?; - Some(Self { - min_prefix_bytes, - has_odd_byte, - }) - } - - pub fn from_bytes(bytes: &[u8]) -> Self { - Self { - min_prefix_bytes: bytes.to_owned(), - has_odd_byte: false, - } - } - - /// Returns a new `HexPrefix` representing the given `id`. - pub fn from_id(id: &T) -> Self { - Self::from_bytes(id.as_bytes()) - } - - /// Returns string representation of this prefix using hex digits. - pub fn hex(&self) -> String { - let mut hex_string = hex_util::encode_hex(&self.min_prefix_bytes); - if self.has_odd_byte { - hex_string.pop().unwrap(); - } - hex_string - } - - /// Returns string representation of this prefix using `z-k` "digits". - pub fn reverse_hex(&self) -> String { - let mut hex_string = hex_util::encode_reverse_hex(&self.min_prefix_bytes); - if self.has_odd_byte { - hex_string.pop().unwrap(); - } - hex_string - } - - /// Minimum bytes that would match this prefix. (e.g. "abc0" for "abc") - /// - /// Use this to partition a sorted slice, and test `matches(id)` from there. - pub fn min_prefix_bytes(&self) -> &[u8] { - &self.min_prefix_bytes - } - - /// Returns the bytes representation if this prefix can be a full id. - pub fn as_full_bytes(&self) -> Option<&[u8]> { - (!self.has_odd_byte).then_some(&self.min_prefix_bytes) - } - - fn split_odd_byte(&self) -> (Option, &[u8]) { - if self.has_odd_byte { - let (&odd, prefix) = self.min_prefix_bytes.split_last().unwrap(); - (Some(odd), prefix) - } else { - (None, &self.min_prefix_bytes) - } - } - - /// Returns whether the stored prefix matches the prefix of `id`. - pub fn matches(&self, id: &Q) -> bool { - let id_bytes = id.as_bytes(); - let (maybe_odd, prefix) = self.split_odd_byte(); - if id_bytes.starts_with(prefix) { - if let Some(odd) = maybe_odd { - matches!(id_bytes.get(prefix.len()), Some(v) if v & 0xf0 == odd) - } else { - true - } - } else { - false - } - } -} - -impl Debug for HexPrefix { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { - f.debug_tuple("HexPrefix").field(&self.hex()).finish() - } -} - -/// The result of a prefix search. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrefixResolution { - NoMatch, - SingleMatch(T), - AmbiguousMatch, -} - -impl PrefixResolution { - pub fn map(self, f: impl FnOnce(T) -> U) -> PrefixResolution { - match self { - Self::NoMatch => PrefixResolution::NoMatch, - Self::SingleMatch(x) => PrefixResolution::SingleMatch(f(x)), - Self::AmbiguousMatch => PrefixResolution::AmbiguousMatch, - } - } - - pub fn filter_map(self, f: impl FnOnce(T) -> Option) -> PrefixResolution { - match self { - Self::NoMatch => PrefixResolution::NoMatch, - Self::SingleMatch(x) => match f(x) { - None => PrefixResolution::NoMatch, - Some(y) => PrefixResolution::SingleMatch(y), - }, - Self::AmbiguousMatch => PrefixResolution::AmbiguousMatch, - } - } -} - -impl PrefixResolution { - pub fn plus(&self, other: &Self) -> Self { - match (self, other) { - (Self::NoMatch, other) => other.clone(), - (local, Self::NoMatch) => local.clone(), - (Self::AmbiguousMatch, _) => Self::AmbiguousMatch, - (_, Self::AmbiguousMatch) => Self::AmbiguousMatch, - (Self::SingleMatch(_), Self::SingleMatch(_)) => Self::AmbiguousMatch, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::ChangeId; - use crate::backend::CommitId; - - #[test] - fn test_display_object_id() { - let commit_id = CommitId::from_hex("deadbeef0123"); - assert_eq!(format!("{commit_id}"), "deadbeef0123"); - assert_eq!(format!("{commit_id:.6}"), "deadbe"); - - let change_id = ChangeId::from_hex("deadbeef0123"); - assert_eq!(format!("{change_id}"), "mlpmollkzyxw"); - assert_eq!(format!("{change_id:.6}"), "mlpmol"); - } - - #[test] - fn test_hex_prefix_prefixes() { - let prefix = HexPrefix::try_from_hex("").unwrap(); - assert_eq!(prefix.min_prefix_bytes(), b""); - - let prefix = HexPrefix::try_from_hex("1").unwrap(); - assert_eq!(prefix.min_prefix_bytes(), b"\x10"); - - let prefix = HexPrefix::try_from_hex("12").unwrap(); - assert_eq!(prefix.min_prefix_bytes(), b"\x12"); - - let prefix = HexPrefix::try_from_hex("123").unwrap(); - assert_eq!(prefix.min_prefix_bytes(), b"\x12\x30"); - - let bad_prefix = HexPrefix::try_from_hex("0x123"); - assert_eq!(bad_prefix, None); - - let bad_prefix = HexPrefix::try_from_hex("foobar"); - assert_eq!(bad_prefix, None); - } - - #[test] - fn test_hex_prefix_matches() { - let id = CommitId::from_hex("1234"); - - assert!(HexPrefix::try_from_hex("").unwrap().matches(&id)); - assert!(HexPrefix::try_from_hex("1").unwrap().matches(&id)); - assert!(HexPrefix::try_from_hex("12").unwrap().matches(&id)); - assert!(HexPrefix::try_from_hex("123").unwrap().matches(&id)); - assert!(HexPrefix::try_from_hex("1234").unwrap().matches(&id)); - assert!(!HexPrefix::try_from_hex("12345").unwrap().matches(&id)); - - assert!(!HexPrefix::try_from_hex("a").unwrap().matches(&id)); - assert!(!HexPrefix::try_from_hex("1a").unwrap().matches(&id)); - assert!(!HexPrefix::try_from_hex("12a").unwrap().matches(&id)); - assert!(!HexPrefix::try_from_hex("123a").unwrap().matches(&id)); - } -} +//! Contains helpers and macros to make it easier to work with binary Object +//! types, with a newtype wrapper [`id_type`] and the [`ObjectId`] trait. + +pub use jj_core::object_id::HexPrefix; +pub use jj_core::object_id::ObjectId; +pub use jj_core::object_id::PrefixResolution; +pub use jj_core::object_id::id_type; +pub use jj_core::object_id::impl_id_type; diff --git a/lib/src/signing.rs b/lib/src/signing.rs index 54a9a29045f..f2d403af4fd 100644 --- a/lib/src/signing.rs +++ b/lib/src/signing.rs @@ -15,11 +15,12 @@ //! Generic APIs to work with cryptographic signatures created and verified by //! various backends. -use std::fmt::Debug; -use std::fmt::Display; -use std::sync::Mutex; - -use clru::CLruCache; +pub use jj_core::signing::SigStatus; +pub use jj_core::signing::SignError; +pub use jj_core::signing::SignResult; +use jj_core::signing::Signer as CoreSigner; +pub use jj_core::signing::SigningBackend; +pub use jj_core::signing::Verification; use thiserror::Error; use crate::backend::CommitId; @@ -28,108 +29,9 @@ use crate::gpg_signing::GpgBackend; use crate::gpg_signing::GpgsmBackend; use crate::settings::UserSettings; use crate::ssh_signing::SshBackend; -use crate::store::COMMIT_CACHE_CAPACITY; #[cfg(feature = "testing")] use crate::test_signing_backend::TestSigningBackend; -/// A status of the signature, part of the [Verification] type. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SigStatus { - /// Valid signature that matches the data. - Good, - /// Valid signature that could not be verified (e.g. due to an unknown key). - Unknown, - /// Valid signature that does not match the signed data. - Bad, -} - -impl Display for SigStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::Good => "good", - Self::Unknown => "unknown", - Self::Bad => "bad", - }; - write!(f, "{s}") - } -} - -/// The result of a signature verification. -/// Key and display are optional additional info that backends can or can not -/// provide to add additional information for the templater to potentially show. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Verification { - /// The status of the signature. - pub status: SigStatus, - /// The key id representation, if available. For GPG, this will be the key - /// fingerprint. - pub key: Option, - /// A display string, if available. For GPG, this will be formatted primary - /// user ID. - pub display: Option, -} - -impl Verification { - /// A shortcut to create an `Unknown` verification with no additional - /// metadata. - pub fn unknown() -> Self { - Self { - status: SigStatus::Unknown, - key: None, - display: None, - } - } - - /// Create a new verification - pub fn new(status: SigStatus, key: Option, display: Option) -> Self { - Self { - status, - key, - display, - } - } -} - -/// The backend for signing and verifying cryptographic signatures. -/// -/// This allows using different signers, such as GPG or SSH, or different -/// versions of them. -pub trait SigningBackend: Debug + Send + Sync { - /// Name of the backend, used in the config and for display. - fn name(&self) -> &str; - - /// Check if the signature can be read and verified by this backend. - /// - /// Should check the signature format, usually just looks at the prefix. - fn can_read(&self, signature: &[u8]) -> bool; - - /// Create a signature for arbitrary data. - /// - /// The `key` parameter is what `jj sign` receives as key argument, or what - /// is configured in the `signing.key` config. - fn sign(&self, data: &[u8], key: Option<&str>) -> SignResult>; - - /// Verify a signature. Should be reflexive with `sign`: - /// ```rust,ignore - /// verify(data, sign(data)?)?.status == SigStatus::Good - /// ``` - fn verify(&self, data: &[u8], signature: &[u8]) -> SignResult; -} - -/// An error type for the signing/verifying operations -#[derive(Debug, Error)] -pub enum SignError { - /// The verification failed because the signature *format* was invalid. - #[error("Invalid signature")] - InvalidSignatureFormat, - /// A generic error from the backend impl. - #[error("Signing error")] - Backend(#[source] Box), -} - -/// A result type for the signing/verifying operations -pub type SignResult = Result; - /// An error type for the signing backend initialization. #[derive(Debug, Error)] pub enum SignInitError { @@ -164,14 +66,8 @@ pub enum SignBehavior { /// Wraps low-level signing backends and adds caching, similar to `Store`. #[derive(Debug)] pub struct Signer { - /// The backend that is used for signing commits. - /// Optional because signing might not be configured. - main_backend: Option>, - /// All known backends without the main one - used for verification. - /// Main backend is also used for verification, but it's not in this list - /// for ownership reasons. - backends: Vec>, - cache: Mutex>, + /// The CoreSigner contains all fields. + inner: CoreSigner, } impl Signer { @@ -206,25 +102,19 @@ impl Signer { main_backend: Option>, other_backends: Vec>, ) -> Self { - Self { - main_backend, - backends: other_backends, - cache: Mutex::new(CLruCache::new(COMMIT_CACHE_CAPACITY)), - } + let inner = CoreSigner::new(main_backend, other_backends); + Self { inner } } /// Checks if the signer can sign, i.e. if a main backend is configured. pub fn can_sign(&self) -> bool { - self.main_backend.is_some() + self.inner.can_sign() } /// This is just a pass-through to the main backend that unconditionally /// creates a signature. pub fn sign(&self, data: &[u8], key: Option<&str>) -> SignResult> { - self.main_backend - .as_ref() - .expect("tried to sign without checking can_sign first") - .sign(data, key) + self.inner.sign(data, key) } /// Looks for backend that can verify the signature and returns the result @@ -235,46 +125,6 @@ impl Signer { data: &[u8], signature: &[u8], ) -> SignResult { - let cached = self.cache.lock().unwrap().get(commit_id).cloned(); - if let Some(check) = cached { - return Ok(check); - } - - let verification = self - .main_backend - .iter() - .chain(self.backends.iter()) - .filter(|b| b.can_read(signature)) - // skip unknown and invalid sigs to allow other backends that can read to try - // for example, we might have gpg and sq, both of which could read a PGP signature - .find_map(|backend| match backend.verify(data, signature) { - Ok(check) if check.status == SigStatus::Unknown => None, - Err(SignError::InvalidSignatureFormat) => None, - e => Some(e), - }) - .transpose()?; - - if let Some(verification) = verification { - // a key might get imported before next call?. - // realistically this is unlikely, but technically - // it's correct to not cache unknowns here - if verification.status != SigStatus::Unknown { - self.cache - .lock() - .unwrap() - .put(commit_id.clone(), verification.clone()); - } - Ok(verification) - } else { - // now here it's correct to cache unknowns, as we don't - // have a backend that knows how to handle this signature - // - // not sure about how much of an optimization this is - self.cache - .lock() - .unwrap() - .put(commit_id.clone(), Verification::unknown()); - Ok(Verification::unknown()) - } + self.inner.verify(commit_id, data, signature) } } From a66c26a5de9ffe39c44154ad851c11a4f85596a2 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Sun, 7 Jun 2026 22:10:57 +0200 Subject: [PATCH 6/8] core: Lower all Revset related utilities to it Move the pest grammar, the parser and all DSL related parts to the new crate. Its needed so we can move the `WorkspaceName` and `WorkspaceNameBuf` newtypes for the `WorkingCopyStore` trait. See the next patch. Part of #6284 --- Cargo.lock | 12 +- lib/core/Cargo.toml | 3 + lib/core/src/dsl_util.rs | 1087 +++++++++++++++++ lib/core/src/lib.rs | 4 + lib/{ => core}/src/ref_name.rs | 6 +- lib/{ => core}/src/revset.pest | 0 lib/core/src/revset.rs | 44 + lib/core/src/revset_parser.rs | 2012 ++++++++++++++++++++++++++++++++ lib/src/dsl_util.rs | 1094 +---------------- lib/src/lib.rs | 2 +- lib/src/revset.rs | 28 +- lib/src/revset_parser.rs | 1968 +------------------------------ 12 files changed, 3210 insertions(+), 3050 deletions(-) create mode 100644 lib/core/src/dsl_util.rs rename lib/{ => core}/src/ref_name.rs (99%) rename lib/{ => core}/src/revset.pest (100%) create mode 100644 lib/core/src/revset.rs create mode 100644 lib/core/src/revset_parser.rs diff --git a/Cargo.lock b/Cargo.lock index 5f47d9e98ea..d8cbd26e9bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,9 +337,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -2650,11 +2650,14 @@ dependencies = [ "insta", "itertools 0.15.0", "jj-core-proc-macros", + "pest", + "pest_derive", "pollster", "ref-cast", "same-file", "serde", "smallvec", + "strsim", "tempfile", "test-case", "thiserror 2.0.19", @@ -3197,11 +3200,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml index b42d9d59341..9dc18946568 100644 --- a/lib/core/Cargo.toml +++ b/lib/core/Cargo.toml @@ -21,10 +21,13 @@ etcetera = { workspace = true } futures = { workspace = true } itertools = { workspace = true } jj-core-proc-macros = { workspace = true } +pest = { workspace = true } +pest_derive = { workspace = true } pollster = { workspace = true } ref-cast = { workspace = true } serde = { workspace = true } smallvec = { workspace = true } +strsim = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/lib/core/src/dsl_util.rs b/lib/core/src/dsl_util.rs new file mode 100644 index 00000000000..0bad038f3f9 --- /dev/null +++ b/lib/core/src/dsl_util.rs @@ -0,0 +1,1087 @@ +// Copyright 2020-2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Domain-specific language helpers. + +use std::ascii; +use std::collections::HashMap; +use std::fmt; +use std::slice; + +use itertools::Itertools as _; +use pest::RuleType; +use pest::iterators::Pair; +use pest::iterators::Pairs; + +/// Manages diagnostic messages emitted during parsing. +/// +/// `T` is usually a parse error type of the language, which contains a message +/// and source span of 'static lifetime. +#[derive(Debug)] +pub struct Diagnostics { + // This might be extended to [{ kind: Warning|Error, message: T }, ..]. + diagnostics: Vec, +} + +impl Diagnostics { + /// Creates new empty diagnostics collector. + pub fn new() -> Self { + Self { + diagnostics: Vec::new(), + } + } + + /// Returns `true` if there are no diagnostic messages. + pub fn is_empty(&self) -> bool { + self.diagnostics.is_empty() + } + + /// Returns the number of diagnostic messages. + pub fn len(&self) -> usize { + self.diagnostics.len() + } + + /// Returns iterator over diagnostic messages. + pub fn iter(&self) -> slice::Iter<'_, T> { + self.diagnostics.iter() + } + + /// Adds a diagnostic message of warning level. + pub fn add_warning(&mut self, diag: T) { + self.diagnostics.push(diag); + } + + /// Moves diagnostic messages of different type (such as fileset warnings + /// emitted within `file()` revset.) + pub fn extend_with(&mut self, diagnostics: Diagnostics, mut f: impl FnMut(U) -> T) { + self.diagnostics + .extend(diagnostics.diagnostics.into_iter().map(&mut f)); + } +} + +impl Default for Diagnostics { + fn default() -> Self { + Self::new() + } +} + +impl<'a, T> IntoIterator for &'a Diagnostics { + type Item = &'a T; + type IntoIter = slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// AST node without type or name checking. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpressionNode<'i, T> { + /// Expression item such as identifier, literal, function call, etc. + pub kind: T, + /// Span of the node. + pub span: pest::Span<'i>, +} + +impl<'i, T> ExpressionNode<'i, T> { + /// Wraps the given expression and span. + pub fn new(kind: T, span: pest::Span<'i>) -> Self { + Self { kind, span } + } +} + +/// `:` expression in AST. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PatternNode<'i, T> { + /// Pattern name or type (such as `glob`.) + pub name: &'i str, + /// Span of the pattern name. + pub name_span: pest::Span<'i>, + /// Value expression. + pub value: ExpressionNode<'i, T>, +} + +/// Function call in AST. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FunctionCallNode<'i, T> { + /// Function name. + pub name: &'i str, + /// Span of the function name. + pub name_span: pest::Span<'i>, + /// List of positional arguments. + pub args: Vec>, + /// List of keyword arguments. + pub keyword_args: Vec>, + /// Span of the arguments list. + pub args_span: pest::Span<'i>, +} + +/// Keyword argument pair in AST. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeywordArgument<'i, T> { + /// Parameter name. + pub name: &'i str, + /// Span of the parameter name. + pub name_span: pest::Span<'i>, + /// Value expression. + pub value: ExpressionNode<'i, T>, +} + +impl<'i, T> FunctionCallNode<'i, T> { + /// Number of arguments assuming named arguments are all unique. + pub fn arity(&self) -> usize { + self.args.len() + self.keyword_args.len() + } + + /// Ensures that no arguments passed. + pub fn expect_no_arguments(&self) -> Result<(), InvalidArguments<'i>> { + let ([], []) = self.expect_arguments()?; + Ok(()) + } + + /// Extracts exactly N required arguments. + pub fn expect_exact_arguments( + &self, + ) -> Result<&[ExpressionNode<'i, T>; N], InvalidArguments<'i>> { + let (args, []) = self.expect_arguments()?; + Ok(args) + } + + /// Extracts N required arguments and remainders. + /// + /// This can be used to get all the positional arguments without requiring + /// any (N = 0): + /// ```ignore + /// let ([], content_nodes) = function.expect_some_arguments()?; + /// ``` + /// Avoid accessing `function.args` directly, as that may allow keyword + /// arguments to be silently ignored. + #[expect(clippy::type_complexity)] + pub fn expect_some_arguments( + &self, + ) -> Result<(&[ExpressionNode<'i, T>; N], &[ExpressionNode<'i, T>]), InvalidArguments<'i>> { + self.ensure_no_keyword_arguments()?; + if self.args.len() >= N { + let (required, rest) = self.args.split_at(N); + Ok((required.try_into().unwrap(), rest)) + } else { + Err(self.invalid_arguments_count(N, None)) + } + } + + /// Extracts N required arguments and M optional arguments. + #[expect(clippy::type_complexity)] + pub fn expect_arguments( + &self, + ) -> Result< + ( + &[ExpressionNode<'i, T>; N], + [Option<&ExpressionNode<'i, T>>; M], + ), + InvalidArguments<'i>, + > { + self.ensure_no_keyword_arguments()?; + let count_range = N..=(N + M); + if count_range.contains(&self.args.len()) { + let (required, rest) = self.args.split_at(N); + let mut optional = rest.iter().map(Some).collect_vec(); + optional.resize(M, None); + Ok(( + required.try_into().unwrap(), + optional.try_into().ok().unwrap(), + )) + } else { + let (min, max) = count_range.into_inner(); + Err(self.invalid_arguments_count(min, Some(max))) + } + } + + /// Extracts N required arguments and M optional arguments. Some of them can + /// be specified as keyword arguments. + /// + /// `names` is a list of parameter names. Unnamed positional arguments + /// should be padded with `""`. + #[expect(clippy::type_complexity)] + pub fn expect_named_arguments( + &self, + names: &[&str], + ) -> Result< + ( + [&ExpressionNode<'i, T>; N], + [Option<&ExpressionNode<'i, T>>; M], + ), + InvalidArguments<'i>, + > { + if self.keyword_args.is_empty() { + let (required, optional) = self.expect_arguments::()?; + Ok((required.each_ref(), optional)) + } else { + let (required, optional) = self.expect_named_arguments_vec(names, N, N + M)?; + Ok(( + required.try_into().ok().unwrap(), + optional.try_into().ok().unwrap(), + )) + } + } + + #[expect(clippy::type_complexity)] + fn expect_named_arguments_vec( + &self, + names: &[&str], + min: usize, + max: usize, + ) -> Result< + ( + Vec<&ExpressionNode<'i, T>>, + Vec>>, + ), + InvalidArguments<'i>, + > { + assert!(names.len() <= max); + + if self.args.len() > max { + return Err(self.invalid_arguments_count(min, Some(max))); + } + let mut extracted = Vec::with_capacity(max); + extracted.extend(self.args.iter().map(Some)); + extracted.resize(max, None); + + for arg in &self.keyword_args { + let name = arg.name; + let span = arg.name_span.start_pos().span(&arg.value.span.end_pos()); + let pos = names.iter().position(|&n| n == name).ok_or_else(|| { + self.invalid_arguments(format!(r#"Unexpected keyword argument "{name}""#), span) + })?; + if extracted[pos].is_some() { + return Err(self.invalid_arguments( + format!(r#"Got multiple values for keyword "{name}""#), + span, + )); + } + extracted[pos] = Some(&arg.value); + } + + let optional = extracted.split_off(min); + let required = extracted.into_iter().flatten().collect_vec(); + if required.len() != min { + return Err(self.invalid_arguments_count(min, Some(max))); + } + Ok((required, optional)) + } + + fn ensure_no_keyword_arguments(&self) -> Result<(), InvalidArguments<'i>> { + if let (Some(first), Some(last)) = (self.keyword_args.first(), self.keyword_args.last()) { + let span = first.name_span.start_pos().span(&last.value.span.end_pos()); + Err(self.invalid_arguments("Unexpected keyword arguments".to_owned(), span)) + } else { + Ok(()) + } + } + + fn invalid_arguments(&self, message: String, span: pest::Span<'i>) -> InvalidArguments<'i> { + InvalidArguments { + name: self.name, + message, + span, + } + } + + fn invalid_arguments_count(&self, min: usize, max: Option) -> InvalidArguments<'i> { + let message = match (min, max) { + (min, Some(max)) if min == max => format!("Expected {min} arguments"), + (min, Some(max)) => format!("Expected {min} to {max} arguments"), + (min, None) => format!("Expected at least {min} arguments"), + }; + self.invalid_arguments(message, self.args_span) + } + + fn invalid_arguments_count_with_arities( + &self, + arities: impl IntoIterator, + ) -> InvalidArguments<'i> { + let message = format!("Expected {} arguments", arities.into_iter().join(", ")); + self.invalid_arguments(message, self.args_span) + } +} + +/// Unexpected number of arguments, or invalid combination of arguments. +/// +/// This error is supposed to be converted to language-specific parse error +/// type, where lifetime `'i` will be eliminated. +#[derive(Clone, Debug)] +pub struct InvalidArguments<'i> { + /// Function name. + pub name: &'i str, + /// Error message. + pub message: String, + /// Span of the bad arguments. + pub span: pest::Span<'i>, +} + +/// Expression item that can be transformed recursively by using `folder: F`. +pub trait FoldableExpression<'i>: Sized { + /// Transforms `self` by applying the `folder` to inner items. + fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result + where + F: ExpressionFolder<'i, Self> + ?Sized; +} + +/// Visitor-like interface to transform AST nodes recursively. +pub trait ExpressionFolder<'i, T: FoldableExpression<'i>> { + /// Transform error. + type Error; + + /// Transforms the expression `node`. By default, inner items are + /// transformed recursively. + fn fold_expression( + &mut self, + node: ExpressionNode<'i, T>, + ) -> Result, Self::Error> { + let ExpressionNode { kind, span } = node; + let kind = kind.fold(self, span)?; + Ok(ExpressionNode { kind, span }) + } + + /// Transforms identifier. + fn fold_identifier(&mut self, name: &'i str, span: pest::Span<'i>) -> Result; + + /// Transforms pattern. + fn fold_pattern( + &mut self, + pattern: Box>, + span: pest::Span<'i>, + ) -> Result; + + /// Transforms function call. + fn fold_function_call( + &mut self, + function: Box>, + span: pest::Span<'i>, + ) -> Result; +} + +/// Transforms list of `nodes` by using `folder`. +pub fn fold_expression_nodes<'i, F, T>( + folder: &mut F, + nodes: Vec>, +) -> Result>, F::Error> +where + F: ExpressionFolder<'i, T> + ?Sized, + T: FoldableExpression<'i>, +{ + nodes + .into_iter() + .map(|node| folder.fold_expression(node)) + .try_collect() +} + +/// Transforms pattern value by using `folder`. +pub fn fold_pattern_value<'i, F, T>( + folder: &mut F, + pattern: PatternNode<'i, T>, +) -> Result, F::Error> +where + F: ExpressionFolder<'i, T> + ?Sized, + T: FoldableExpression<'i>, +{ + Ok(PatternNode { + name: pattern.name, + name_span: pattern.name_span, + value: folder.fold_expression(pattern.value)?, + }) +} + +/// Transforms function call arguments by using `folder`. +pub fn fold_function_call_args<'i, F, T>( + folder: &mut F, + function: FunctionCallNode<'i, T>, +) -> Result, F::Error> +where + F: ExpressionFolder<'i, T> + ?Sized, + T: FoldableExpression<'i>, +{ + Ok(FunctionCallNode { + name: function.name, + name_span: function.name_span, + args: fold_expression_nodes(folder, function.args)?, + keyword_args: function + .keyword_args + .into_iter() + .map(|arg| { + Ok(KeywordArgument { + name: arg.name, + name_span: arg.name_span, + value: folder.fold_expression(arg.value)?, + }) + }) + .try_collect()?, + args_span: function.args_span, + }) +} + +/// Helper to parse string literal. +#[derive(Debug)] +pub struct StringLiteralParser { + /// String content part. + pub content_rule: R, + /// Escape sequence part including backslash character. + pub escape_rule: R, +} + +impl StringLiteralParser { + /// Parses the given string literal `pairs` into string. + pub fn parse(&self, pairs: Pairs) -> String { + let mut result = String::new(); + for part in pairs { + if part.as_rule() == self.content_rule { + result.push_str(part.as_str()); + } else if part.as_rule() == self.escape_rule { + match &part.as_str()[1..] { + "\"" => result.push('"'), + "\\" => result.push('\\'), + "t" => result.push('\t'), + "r" => result.push('\r'), + "n" => result.push('\n'), + "0" => result.push('\0'), + "e" => result.push('\x1b'), + hex if hex.starts_with('x') => { + result.push(char::from( + u8::from_str_radix(&hex[1..], 16).expect("hex characters"), + )); + } + char => panic!("invalid escape: \\{char:?}"), + } + } else { + panic!("unexpected part of string: {part:?}"); + } + } + result + } +} + +/// Escape special characters in the input +pub fn escape_string(unescaped: &str) -> String { + let mut escaped = String::with_capacity(unescaped.len()); + for c in unescaped.chars() { + match c { + '"' => escaped.push_str(r#"\""#), + '\\' => escaped.push_str(r#"\\"#), + '\t' => escaped.push_str(r#"\t"#), + '\r' => escaped.push_str(r#"\r"#), + '\n' => escaped.push_str(r#"\n"#), + '\0' => escaped.push_str(r#"\0"#), + c if c.is_ascii_control() => { + for b in ascii::escape_default(c as u8) { + escaped.push(b as char); + } + } + c => escaped.push(c), + } + } + escaped +} + +/// Helper to parse function call. +#[derive(Debug)] +pub struct FunctionCallParser { + /// Function name. + pub function_name_rule: R, + /// List of positional and keyword arguments. + pub function_arguments_rule: R, + /// Pair of parameter name and value. + pub keyword_argument_rule: R, + /// Parameter name. + pub argument_name_rule: R, + /// Value expression. + pub argument_value_rule: R, +} + +impl FunctionCallParser { + /// Parses the given `pair` as function call. + pub fn parse<'i, T, E: From>>( + &self, + pair: Pair<'i, R>, + // parse_name can be defined for any Pair<'_, R>, but parse_value should + // be allowed to construct T by capturing Pair<'i, R>. + parse_name: impl Fn(Pair<'i, R>) -> Result<&'i str, E>, + parse_value: impl Fn(Pair<'i, R>) -> Result, E>, + ) -> Result, E> { + let [name_pair, args_pair] = pair.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), self.function_name_rule); + assert_eq!(args_pair.as_rule(), self.function_arguments_rule); + let name_span = name_pair.as_span(); + let args_span = args_pair.as_span(); + let function_name = parse_name(name_pair)?; + let mut args = Vec::new(); + let mut keyword_args = Vec::new(); + for pair in args_pair.into_inner() { + let span = pair.as_span(); + if pair.as_rule() == self.argument_value_rule { + if !keyword_args.is_empty() { + return Err(InvalidArguments { + name: function_name, + message: "Positional argument follows keyword argument".to_owned(), + span, + } + .into()); + } + args.push(parse_value(pair)?); + } else if pair.as_rule() == self.keyword_argument_rule { + let [name_pair, value_pair] = pair.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), self.argument_name_rule); + assert_eq!(value_pair.as_rule(), self.argument_value_rule); + let name_span = name_pair.as_span(); + let arg = KeywordArgument { + name: parse_name(name_pair)?, + name_span, + value: parse_value(value_pair)?, + }; + keyword_args.push(arg); + } else { + panic!("unexpected argument rule {pair:?}"); + } + } + Ok(FunctionCallNode { + name: function_name, + name_span, + args, + keyword_args, + args_span, + }) + } +} + +/// A function alias containing `(params, definition, description)`. +type FunctionAlias = (Vec, V, Option); + +/// Map of symbol, pattern, and function aliases. +#[derive(Clone, Debug, Default)] +pub struct AliasesMap { + symbol_aliases: HashMap)>, + // name: (param, defn) + pattern_aliases: HashMap)>, + // name: [(params, defn)] (sorted by arity) + function_aliases: HashMap>>, + // Parser type P helps prevent misuse of AliasesMap of different language. + parser: P, +} + +impl AliasesMap { + /// Creates an empty aliases map with default-constructed parser. + pub fn new() -> Self + where + P: Default, + { + Self { + symbol_aliases: Default::default(), + pattern_aliases: Default::default(), + function_aliases: Default::default(), + parser: Default::default(), + } + } + + /// Adds new substitution rule `decl = defn`. + /// + /// Returns error if `decl` is invalid. The `defn` part isn't checked. A bad + /// `defn` will be reported when the alias is substituted. + pub fn insert( + &mut self, + decl: impl AsRef, + defn: impl Into, + doc: Option, + ) -> Result<(), P::Error> + where + P: AliasDeclarationParser, + { + match self.parser.parse_declaration(decl.as_ref())? { + AliasDeclaration::Symbol(name) => { + self.symbol_aliases.insert(name, (defn.into(), doc)); + } + AliasDeclaration::Pattern(name, param) => { + self.pattern_aliases.insert(name, (param, defn.into(), doc)); + } + AliasDeclaration::Function(name, params) => { + let overloads = self.function_aliases.entry(name).or_default(); + match overloads.binary_search_by_key(¶ms.len(), |(params, _, _)| params.len()) { + Ok(i) => overloads[i] = (params, defn.into(), doc), + Err(i) => overloads.insert(i, (params, defn.into(), doc)), + } + } + } + Ok(()) + } + + /// Iterates symbol names in arbitrary order. + pub fn symbol_names(&self) -> impl Iterator { + self.symbol_aliases.keys().map(|n| n.as_ref()) + } + + /// Iterates pattern names in arbitrary order. + pub fn pattern_names(&self) -> impl Iterator { + self.pattern_aliases.keys().map(|n| n.as_ref()) + } + + /// Iterates function names in arbitrary order. + pub fn function_names(&self) -> impl Iterator { + self.function_aliases.keys().map(|n| n.as_ref()) + } + + /// Looks up symbol alias by name. Returns identifier, definition text, and + /// optional description. + pub fn get_symbol(&self, name: &str) -> Option<(AliasId<'_>, &V, Option<&str>)> { + self.symbol_aliases + .get_key_value(name) + .map(|(name, (defn, doc))| (AliasId::Symbol(name), defn, doc.as_deref())) + } + + /// Looks up pattern alias by name. Returns identifier, parameter name, + /// definition text, and optional description. + pub fn get_pattern(&self, name: &str) -> Option<(AliasId<'_>, &str, &V, Option<&str>)> { + self.pattern_aliases + .get_key_value(name) + .map(|(name, (param, defn, doc))| { + ( + AliasId::Pattern(name, param), + param.as_ref(), + defn, + doc.as_deref(), + ) + }) + } + + /// Looks up function alias by name and arity. Returns identifier, list of + /// parameter names, definition text, and optional description. + pub fn get_function( + &self, + name: &str, + arity: usize, + ) -> Option<(AliasId<'_>, &[String], &V, Option<&str>)> { + let overloads = self.get_function_overloads(name)?; + overloads.find_by_arity(arity) + } + + /// Looks up function aliases by name. + fn get_function_overloads(&self, name: &str) -> Option> { + let (name, overloads) = self.function_aliases.get_key_value(name)?; + Some(AliasFunctionOverloads { name, overloads }) + } +} + +#[derive(Clone, Debug)] +struct AliasFunctionOverloads<'a, V> { + name: &'a String, + overloads: &'a Vec<(Vec, V, Option)>, +} + +impl<'a, V> AliasFunctionOverloads<'a, V> { + fn arities(&self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.overloads.iter().map(|(params, _, _)| params.len()) + } + + fn min_arity(&self) -> usize { + self.arities().next().unwrap() + } + + fn max_arity(&self) -> usize { + self.arities().next_back().unwrap() + } + + fn find_by_arity( + &self, + arity: usize, + ) -> Option<(AliasId<'a>, &'a [String], &'a V, Option<&'a str>)> { + let index = self + .overloads + .binary_search_by_key(&arity, |(params, _, _)| params.len()) + .ok()?; + let (params, defn, doc) = &self.overloads[index]; + // Exact parameter names aren't needed to identify a function, but they + // provide a better error indication. (e.g. "foo(x, y)" is easier to + // follow than "foo/2".) + Some(( + AliasId::Function(self.name, params), + params, + defn, + doc.as_deref(), + )) + } +} + +/// Borrowed reference to identify alias expression. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AliasId<'a> { + /// Symbol name. + Symbol(&'a str), + /// Pattern name and parameter name. + Pattern(&'a str, &'a str), + /// Function name and parameter names. + Function(&'a str, &'a [String]), + /// Function parameter name. + Parameter(&'a str), +} + +impl fmt::Display for AliasId<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Symbol(name) => write!(f, "{name}"), + Self::Pattern(name, param) => write!(f, "{name}:{param}"), + Self::Function(name, params) => { + write!(f, "{name}({params})", params = params.join(", ")) + } + Self::Parameter(name) => write!(f, "{name}"), + } + } +} + +/// Parsed declaration part of alias rule. +#[derive(Clone, Debug)] +pub enum AliasDeclaration { + /// Symbol name. + Symbol(String), + /// Pattern name and parameter. + Pattern(String, String), + /// Function name and parameters. + Function(String, Vec), +} + +// AliasDeclarationParser and AliasDefinitionParser can be merged into a single +// trait, but it's unclear whether doing that would simplify the abstraction. + +/// Parser for symbol and function alias declaration. +pub trait AliasDeclarationParser { + /// Parse error type. + type Error; + + /// Parses symbol or function name and parameters. + fn parse_declaration(&self, source: &str) -> Result; +} + +/// Parser for symbol and function alias definition. +pub trait AliasDefinitionParser { + /// Expression item type. + type Output<'i>; + /// Parse error type. + type Error; + + /// Parses alias body. + fn parse_definition<'i>( + &self, + source: &'i str, + ) -> Result>, Self::Error>; +} + +/// Expression item that supports alias substitution. +pub trait AliasExpandableExpression<'i>: FoldableExpression<'i> { + /// Wraps identifier. + fn identifier(name: &'i str) -> Self; + /// Wraps pattern. + fn pattern(pattern: Box>) -> Self; + /// Wraps function call. + fn function_call(function: Box>) -> Self; + /// Wraps substituted expression. + fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self; +} + +/// Error that may occur during alias substitution. +pub trait AliasExpandError: Sized { + /// Unexpected number of arguments, or invalid combination of arguments. + fn invalid_arguments(err: InvalidArguments<'_>) -> Self; + /// Recursion detected during alias substitution. + fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self; + /// Attaches alias trace to the current error. + fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self; +} + +/// Expands aliases recursively in tree of `T`. +#[derive(Debug)] +struct AliasExpander<'i, 'a, T, P> { + /// Alias symbols and functions that are globally available. + aliases_map: &'i AliasesMap, + /// Local variables set in the outermost scope. + locals: &'a HashMap<&'i str, ExpressionNode<'i, T>>, + /// Stack of aliases and local parameters currently expanding. + states: Vec>, +} + +#[derive(Debug)] +struct AliasExpandingState<'i, T> { + id: AliasId<'i>, + locals: HashMap<&'i str, ExpressionNode<'i, T>>, +} + +impl<'i, T, P, E> AliasExpander<'i, '_, T, P> +where + T: AliasExpandableExpression<'i> + Clone, + P: AliasDefinitionParser = T, Error = E>, + E: AliasExpandError, +{ + /// Local variables available to the current scope. + fn current_locals(&self) -> &HashMap<&'i str, ExpressionNode<'i, T>> { + self.states.last().map_or(self.locals, |s| &s.locals) + } + + fn expand_defn( + &mut self, + id: AliasId<'i>, + defn: &'i str, + locals: HashMap<&'i str, ExpressionNode<'i, T>>, + span: pest::Span<'i>, + ) -> Result { + // The stack should be short, so let's simply do linear search. + if self.states.iter().any(|s| s.id == id) { + return Err(E::recursive_expansion(id, span)); + } + self.states.push(AliasExpandingState { id, locals }); + // Parsed defn could be cached if needed. + let result = self + .aliases_map + .parser + .parse_definition(defn) + .and_then(|node| self.fold_expression(node)) + .map(|node| T::alias_expanded(id, Box::new(node))) + .map_err(|e| e.within_alias_expansion(id, span)); + self.states.pop(); + result + } +} + +impl<'i, T, P, E> ExpressionFolder<'i, T> for AliasExpander<'i, '_, T, P> +where + T: AliasExpandableExpression<'i> + Clone, + P: AliasDefinitionParser = T, Error = E>, + E: AliasExpandError, +{ + type Error = E; + + fn fold_identifier(&mut self, name: &'i str, span: pest::Span<'i>) -> Result { + if let Some(subst) = self.current_locals().get(name) { + let id = AliasId::Parameter(name); + Ok(T::alias_expanded(id, Box::new(subst.clone()))) + } else if let Some((id, defn, _doc)) = self.aliases_map.get_symbol(name) { + let locals = HashMap::new(); // Don't spill out the current scope + self.expand_defn(id, defn, locals, span) + } else { + Ok(T::identifier(name)) + } + } + + fn fold_pattern( + &mut self, + pattern: Box>, + span: pest::Span<'i>, + ) -> Result { + if let Some((id, param, defn, _doc)) = self.aliases_map.get_pattern(pattern.name) { + // Resolve argument in the current scope, and pass it in to the + // alias expansion scope. + let arg = self.fold_expression(pattern.value)?; + let locals = HashMap::from([(param, arg)]); + self.expand_defn(id, defn, locals, span) + } else { + let pattern = Box::new(fold_pattern_value(self, *pattern)?); + Ok(T::pattern(pattern)) + } + } + + fn fold_function_call( + &mut self, + function: Box>, + span: pest::Span<'i>, + ) -> Result { + // For better error indication, builtin functions are shadowed by name, + // not by (name, arity). + if let Some(overloads) = self.aliases_map.get_function_overloads(function.name) { + // TODO: add support for keyword arguments + function + .ensure_no_keyword_arguments() + .map_err(E::invalid_arguments)?; + let Some((id, params, defn, _doc)) = overloads.find_by_arity(function.arity()) else { + let min = overloads.min_arity(); + let max = overloads.max_arity(); + let err = if max - min + 1 == overloads.arities().len() { + function.invalid_arguments_count(min, Some(max)) + } else { + function.invalid_arguments_count_with_arities(overloads.arities()) + }; + return Err(E::invalid_arguments(err)); + }; + // Resolve arguments in the current scope, and pass them in to the alias + // expansion scope. + let args = fold_expression_nodes(self, function.args)?; + let locals = params.iter().map(|s| s.as_str()).zip(args).collect(); + self.expand_defn(id, defn, locals, span) + } else { + let function = Box::new(fold_function_call_args(self, *function)?); + Ok(T::function_call(function)) + } + } +} + +/// Expands aliases recursively. +pub fn expand_aliases<'i, T, P>( + node: ExpressionNode<'i, T>, + aliases_map: &'i AliasesMap, +) -> Result, P::Error> +where + T: AliasExpandableExpression<'i> + Clone, + P: AliasDefinitionParser = T>, + P::Error: AliasExpandError, +{ + expand_aliases_with_locals(node, aliases_map, &HashMap::new()) +} + +/// Expands aliases recursively with the outermost local variables. +/// +/// Local variables are similar to alias symbols, but are scoped. Alias symbols +/// are globally accessible from alias expressions, but local variables aren't. +pub fn expand_aliases_with_locals<'i, T, P>( + node: ExpressionNode<'i, T>, + aliases_map: &'i AliasesMap, + locals: &HashMap<&'i str, ExpressionNode<'i, T>>, +) -> Result, P::Error> +where + T: AliasExpandableExpression<'i> + Clone, + P: AliasDefinitionParser = T>, + P::Error: AliasExpandError, +{ + let mut expander = AliasExpander { + aliases_map, + locals, + states: Vec::new(), + }; + expander.fold_expression(node) +} + +/// Collects similar names from the `candidates` list. +pub fn collect_similar(name: &str, candidates: I) -> Vec +where + I: IntoIterator, + I::Item: AsRef, +{ + candidates + .into_iter() + .filter(|cand| { + // The parameter is borrowed from clap f5540d26 + strsim::jaro(name, cand.as_ref()) > 0.7 + }) + .map(|s| s.as_ref().to_owned()) + .sorted_unstable() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expect_arguments() { + fn empty_span() -> pest::Span<'static> { + pest::Span::new("", 0, 0).unwrap() + } + + fn function( + name: &'static str, + args: impl Into>>, + keyword_args: impl Into>>, + ) -> FunctionCallNode<'static, u32> { + FunctionCallNode { + name, + name_span: empty_span(), + args: args.into(), + keyword_args: keyword_args.into(), + args_span: empty_span(), + } + } + + fn value(v: u32) -> ExpressionNode<'static, u32> { + ExpressionNode::new(v, empty_span()) + } + + fn keyword(name: &'static str, v: u32) -> KeywordArgument<'static, u32> { + KeywordArgument { + name, + name_span: empty_span(), + value: value(v), + } + } + + let f = function("foo", [], []); + assert!(f.expect_no_arguments().is_ok()); + assert!(f.expect_some_arguments::<0>().is_ok()); + assert!(f.expect_arguments::<0, 0>().is_ok()); + assert!(f.expect_named_arguments::<0, 0>(&[]).is_ok()); + + let f = function("foo", [value(0)], []); + assert!(f.expect_no_arguments().is_err()); + assert_eq!( + f.expect_some_arguments::<0>().unwrap(), + (&[], [value(0)].as_slice()) + ); + assert_eq!( + f.expect_some_arguments::<1>().unwrap(), + (&[value(0)], [].as_slice()) + ); + assert!(f.expect_arguments::<0, 0>().is_err()); + assert_eq!( + f.expect_arguments::<0, 1>().unwrap(), + (&[], [Some(&value(0))]) + ); + assert_eq!(f.expect_arguments::<1, 1>().unwrap(), (&[value(0)], [None])); + assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); + assert_eq!( + f.expect_named_arguments::<0, 1>(&["a"]).unwrap(), + ([], [Some(&value(0))]) + ); + assert_eq!( + f.expect_named_arguments::<1, 0>(&["a"]).unwrap(), + ([&value(0)], []) + ); + + let f = function("foo", [], [keyword("a", 0)]); + assert!(f.expect_no_arguments().is_err()); + assert!(f.expect_some_arguments::<1>().is_err()); + assert!(f.expect_arguments::<0, 1>().is_err()); + assert!(f.expect_arguments::<1, 0>().is_err()); + assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); + assert!(f.expect_named_arguments::<0, 1>(&[]).is_err()); + assert!(f.expect_named_arguments::<1, 0>(&[]).is_err()); + assert_eq!( + f.expect_named_arguments::<1, 0>(&["a"]).unwrap(), + ([&value(0)], []) + ); + assert_eq!( + f.expect_named_arguments::<1, 1>(&["a", "b"]).unwrap(), + ([&value(0)], [None]) + ); + assert!(f.expect_named_arguments::<1, 1>(&["b", "a"]).is_err()); + + let f = function("foo", [value(0)], [keyword("a", 1), keyword("b", 2)]); + assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); + assert!(f.expect_named_arguments::<1, 1>(&["a", "b"]).is_err()); + assert_eq!( + f.expect_named_arguments::<1, 2>(&["c", "a", "b"]).unwrap(), + ([&value(0)], [Some(&value(1)), Some(&value(2))]) + ); + assert_eq!( + f.expect_named_arguments::<2, 1>(&["c", "b", "a"]).unwrap(), + ([&value(0), &value(2)], [Some(&value(1))]) + ); + assert_eq!( + f.expect_named_arguments::<0, 3>(&["c", "b", "a"]).unwrap(), + ([], [Some(&value(0)), Some(&value(2)), Some(&value(1))]) + ); + + let f = function("foo", [], [keyword("a", 0), keyword("a", 1)]); + assert!(f.expect_named_arguments::<1, 1>(&["", "a"]).is_err()); + } +} diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index c1a2925f613..952055f1aba 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -30,11 +30,15 @@ extern crate self as jj_core; pub mod content_hash; pub mod backend; +pub mod dsl_util; pub mod file_util; pub mod hex_util; pub mod matchers; pub mod object_id; +pub mod ref_name; pub mod repo_path; +pub mod revset; +pub mod revset_parser; pub mod signing; #[cfg(test)] diff --git a/lib/src/ref_name.rs b/lib/core/src/ref_name.rs similarity index 99% rename from lib/src/ref_name.rs rename to lib/core/src/ref_name.rs index 1b8926e4599..7ee5816ccf8 100644 --- a/lib/src/ref_name.rs +++ b/lib/core/src/ref_name.rs @@ -16,18 +16,18 @@ //! //! Name types can be constructed from a string: //! ``` -//! # use jj_lib::ref_name::*; +//! # use jj_core::ref_name::*; //! let _: RefNameBuf = "main".into(); //! let _: &RemoteName = "origin".as_ref(); //! ``` //! //! However, they cannot be converted to other name types: //! ```compile_fail -//! # use jj_lib::ref_name::*; +//! # use jj_core::ref_name::*; //! let _: RefNameBuf = RemoteName::new("origin").into(); //! ``` //! ```compile_fail -//! # use jj_lib::ref_name::*; +//! # use jj_core::ref_name::*; //! let _: &RemoteName = RefName::new("main").as_ref(); //! ``` diff --git a/lib/src/revset.pest b/lib/core/src/revset.pest similarity index 100% rename from lib/src/revset.pest rename to lib/core/src/revset.pest diff --git a/lib/core/src/revset.rs b/lib/core/src/revset.rs new file mode 100644 index 00000000000..8b63f1e0ee5 --- /dev/null +++ b/lib/core/src/revset.rs @@ -0,0 +1,44 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains some basic methods around Revsets. +// TODO: consider moving the optimizer into this. + +use crate::dsl_util; +use crate::revset_parser; + +/// Formats a string as symbol by quoting and escaping it if necessary. +/// +/// Note that symbols may be substituted to user aliases. Use +/// [`format_string()`] to ensure that the provided string is resolved as a +/// tag/bookmark name, commit/change ID prefix, etc. +pub fn format_symbol(literal: &str) -> String { + if revset_parser::is_identifier(literal) { + literal.to_string() + } else { + format_string(literal) + } +} + +/// Formats a string by quoting and escaping it. +pub fn format_string(literal: &str) -> String { + format!(r#""{}""#, dsl_util::escape_string(literal)) +} + +/// Formats a `name@remote` symbol, applies quoting and escaping if necessary. +pub fn format_remote_symbol(name: &str, remote: &str) -> String { + let name = format_symbol(name); + let remote = format_symbol(remote); + format!("{name}@{remote}") +} diff --git a/lib/core/src/revset_parser.rs b/lib/core/src/revset_parser.rs new file mode 100644 index 00000000000..530dbd24f4f --- /dev/null +++ b/lib/core/src/revset_parser.rs @@ -0,0 +1,2012 @@ +// Copyright 2021-2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains the `RevsetParser` and Revset-specific DSL helpers for alias +//! expansions. + +// TODO: finish documenting `revset.pest`. +#![allow(missing_docs)] + +use std::collections::HashSet; +use std::error; +use std::mem; +use std::str::FromStr; +use std::sync::LazyLock; + +use itertools::Itertools as _; +use pest::Parser as _; +use pest::iterators::Pair; +use pest::pratt_parser::Assoc; +use pest::pratt_parser::Op; +use pest::pratt_parser::PrattParser; +use pest_derive::Parser; +use thiserror::Error; + +use crate::dsl_util; +use crate::dsl_util::AliasDeclaration; +use crate::dsl_util::AliasDeclarationParser; +use crate::dsl_util::AliasDefinitionParser; +use crate::dsl_util::AliasExpandError; +use crate::dsl_util::AliasExpandableExpression; +use crate::dsl_util::AliasId; +use crate::dsl_util::AliasesMap; +use crate::dsl_util::Diagnostics; +use crate::dsl_util::ExpressionFolder; +use crate::dsl_util::FoldableExpression; +use crate::dsl_util::FunctionCallParser; +use crate::dsl_util::InvalidArguments; +use crate::dsl_util::StringLiteralParser; +use crate::dsl_util::collect_similar; +use crate::ref_name::RefNameBuf; +use crate::ref_name::RemoteNameBuf; +use crate::ref_name::RemoteRefSymbolBuf; + +/// The [`RevsetParser`] which is generated from the revset.pest grammar file. +#[derive(Parser)] +#[grammar = "revset.pest"] +struct RevsetParser; + +const STRING_LITERAL_PARSER: StringLiteralParser = StringLiteralParser { + content_rule: Rule::string_content, + escape_rule: Rule::string_escape, +}; +const FUNCTION_CALL_PARSER: FunctionCallParser = FunctionCallParser { + function_name_rule: Rule::function_name, + function_arguments_rule: Rule::function_arguments, + keyword_argument_rule: Rule::keyword_argument, + argument_name_rule: Rule::strict_identifier, + argument_value_rule: Rule::expression, +}; + +impl Rule { + /// Whether this is a placeholder rule for compatibility with the other + /// systems. + fn is_compat(&self) -> bool { + matches!( + self, + Self::compat_parents_op + | Self::compat_dag_range_op + | Self::compat_dag_range_pre_op + | Self::compat_dag_range_post_op + | Self::compat_add_op + | Self::compat_sub_op + ) + } + + fn to_symbol(self) -> Option<&'static str> { + match self { + Self::EOI => None, + Self::whitespace => None, + Self::identifier_part => None, + Self::identifier => None, + Self::strict_identifier_part => None, + Self::strict_identifier => None, + Self::symbol => None, + Self::string_escape => None, + Self::string_content_char => None, + Self::string_content => None, + Self::string_literal => None, + Self::raw_string_content => None, + Self::raw_string_literal => None, + Self::at_op => Some("@"), + Self::pattern_kind_op => Some(":"), + Self::parents_op => Some("-"), + Self::children_op => Some("+"), + Self::compat_parents_op => Some("^"), + Self::dag_range_op + | Self::dag_range_pre_op + | Self::dag_range_post_op + | Self::dag_range_all_op => Some("::"), + Self::compat_dag_range_op + | Self::compat_dag_range_pre_op + | Self::compat_dag_range_post_op => Some(":"), + Self::range_op => Some(".."), + Self::range_pre_op | Self::range_post_op | Self::range_all_op => Some(".."), + Self::range_ops => None, + Self::range_pre_ops => None, + Self::range_post_ops => None, + Self::range_all_ops => None, + Self::negate_op => Some("~"), + Self::union_op => Some("|"), + Self::intersection_op => Some("&"), + Self::difference_op => Some("~"), + Self::compat_add_op => Some("+"), + Self::compat_sub_op => Some("-"), + Self::infix_op => None, + Self::function => None, + Self::function_name => None, + Self::keyword_argument => None, + Self::argument => None, + Self::function_arguments => None, + Self::formal_parameters => None, + Self::pattern => None, + Self::pattern_value_expression => None, + Self::primary => None, + Self::neighbors_expression => None, + Self::range_expression => None, + Self::expression => None, + Self::program => None, + Self::symbol_name => None, + Self::function_alias_declaration => None, + Self::pattern_alias_declaration => None, + Self::alias_declaration => None, + } + } +} + +/// Manages diagnostic messages emitted during revset parsing and function-call +/// resolution. +pub type RevsetDiagnostics = Diagnostics; + +/// An error which can occur during Revset parsing. +#[derive(Debug, Error)] +#[error("{pest_error}")] +pub struct RevsetParseError { + kind: Box, + pest_error: Box>, + source: Option>, +} + +/// The RevsetError kind, so the source the error stems from. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RevsetParseErrorKind { + /// A plain Syntax error. + #[error("Syntax error")] + SyntaxError, + /// A wrongly applied operator which isn't a PrefixOperator. + #[error("`{op}` is not a prefix operator")] + NotPrefixOperator { + /// The operator. + op: String, + /// The nearest operator which matches for this case. + similar_op: String, + /// Any additional description. + description: String, + }, + /// A wrongly applied operator which isn't a PostfixOperator. + #[error("`{op}` is not a postfix operator")] + NotPostfixOperator { + /// The operator. + op: String, + /// The nearest operator which matches for this case. + similar_op: String, + /// Any additional description. + description: String, + }, + /// A wrongly applied which isn't a InfixOperator. + #[error("`{op}` is not an infix operator")] + NotInfixOperator { + /// The operator. + op: String, + /// The nearest operator which matches for this case. + similar_op: String, + /// Any additional description. + description: String, + }, + /// A function was not found or doesn't exist. + #[error("Function `{name}` doesn't exist")] + NoSuchFunction { + /// The name of the given function. + name: String, + /// A list of function candidates which match for a substring of `name`. + candidates: Vec, + }, + /// A function received wrong arguments. + #[error("Function `{name}`: {message}")] + InvalidFunctionArguments { + /// The name of the function. + name: String, + /// An additional message which is appended to the error. + message: String, + }, + /// A file pattern was passed without a workspace. + #[error("Cannot resolve file pattern without workspace")] + FsPathWithoutWorkspace, + /// The working-copy was requested without a workspace. + #[error("Cannot resolve `@` without workspace")] + WorkingCopyWithoutWorkspace, + /// A function parameter was redefined. + #[error("Redefinition of function parameter")] + RedefinedFunctionParameter, + /// An expression caused the error. + #[error("{0}")] + Expression(String), + /// A alias failed to expand. + #[error("In alias `{0}`")] + InAliasExpansion(String), + /// A parameter failed to expand. + #[error("In function parameter `{0}`")] + InParameterExpansion(String), + /// There was recursion during the alias expansion. + #[error("Alias `{0}` expanded recursively")] + RecursiveAlias(String), +} + +impl RevsetParseError { + /// Create a new `RevsetParseError` with the given `kind` and `span`. + pub fn with_span(kind: RevsetParseErrorKind, span: pest::Span<'_>) -> Self { + let message = kind.to_string(); + let pest_error = Box::new(pest::error::Error::new_from_span( + pest::error::ErrorVariant::CustomError { message }, + span, + )); + Self { + kind: Box::new(kind), + pest_error, + source: None, + } + } + + /// Add an additional error source to the `RevsetParseError`. + pub fn with_source(mut self, source: impl Into>) -> Self { + self.source = Some(source.into()); + self + } + + /// Some other expression error. + pub fn expression(message: impl Into, span: pest::Span<'_>) -> Self { + Self::with_span(RevsetParseErrorKind::Expression(message.into()), span) + } + + /// If this is a `NoSuchFunction` error, expands the candidates list with + /// the given `other_functions`. + pub fn extend_function_candidates(mut self, other_functions: I) -> Self + where + I: IntoIterator, + I::Item: AsRef, + { + if let RevsetParseErrorKind::NoSuchFunction { name, candidates } = self.kind.as_mut() { + let other_candidates = collect_similar(name, other_functions); + *candidates = itertools::merge(mem::take(candidates), other_candidates) + .dedup() + .collect(); + } + self + } + + /// Gets the `kind` of the error. + pub fn kind(&self) -> &RevsetParseErrorKind { + &self.kind + } + + /// Original parsing error which typically occurred in an alias expression. + pub fn origin(&self) -> Option<&Self> { + self.source.as_ref().and_then(|e| e.downcast_ref()) + } +} + +impl AliasExpandError for RevsetParseError { + fn invalid_arguments(err: InvalidArguments<'_>) -> Self { + err.into() + } + + fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self { + Self::with_span(RevsetParseErrorKind::RecursiveAlias(id.to_string()), span) + } + + fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self { + let kind = match id { + AliasId::Symbol(_) | AliasId::Pattern(..) | AliasId::Function(..) => { + RevsetParseErrorKind::InAliasExpansion(id.to_string()) + } + AliasId::Parameter(_) => RevsetParseErrorKind::InParameterExpansion(id.to_string()), + }; + Self::with_span(kind, span).with_source(self) + } +} + +impl From> for RevsetParseError { + fn from(err: pest::error::Error) -> Self { + Self { + kind: Box::new(RevsetParseErrorKind::SyntaxError), + pest_error: Box::new(rename_rules_in_pest_error(err)), + source: None, + } + } +} + +impl From> for RevsetParseError { + fn from(err: InvalidArguments<'_>) -> Self { + let kind = RevsetParseErrorKind::InvalidFunctionArguments { + name: err.name.to_owned(), + message: err.message, + }; + Self::with_span(kind, err.span) + } +} + +fn rename_rules_in_pest_error(mut err: pest::error::Error) -> pest::error::Error { + let pest::error::ErrorVariant::ParsingError { + positives, + negatives, + } = &mut err.variant + else { + return err; + }; + + // Remove duplicated symbols. Compat symbols are also removed from the + // (positive) suggestion. + let mut known_syms = HashSet::new(); + positives.retain(|rule| { + !rule.is_compat() && rule.to_symbol().is_none_or(|sym| known_syms.insert(sym)) + }); + let mut known_syms = HashSet::new(); + negatives.retain(|rule| rule.to_symbol().is_none_or(|sym| known_syms.insert(sym))); + err.renamed_rules(|rule| { + rule.to_symbol() + .map(|sym| format!("`{sym}`")) + .unwrap_or_else(|| format!("<{rule:?}>")) + }) +} + +/// Describes the kinds of Expressions the Revset parser understands. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExpressionKind<'i> { + /// Unquoted symbol. + Identifier(&'i str), + /// Quoted symbol or string. + String(String), + /// `:` where `` is usually `Identifier` or `String`. + Pattern(Box>), + /// `@` + RemoteSymbol(RemoteRefSymbolBuf), + /// `@` + AtWorkspace(String), + /// `@` + AtCurrentWorkspace, + /// `::` + DagRangeAll, + /// `..` + RangeAll, + /// A urnary expression with its kind and expression. + Unary(UnaryOp, Box>), + /// A binary expression with its kind both primary and secondary expression. + Binary(BinaryOp, Box>, Box>), + /// `x | y | ..` + UnionAll(Vec>), + /// A function call with its specific node. + FunctionCall(Box>), + /// Identity node to preserve the span in the source text. + AliasExpanded(AliasId<'i>, Box>), +} + +impl<'i> FoldableExpression<'i> for ExpressionKind<'i> { + fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result + where + F: ExpressionFolder<'i, Self> + ?Sized, + { + match self { + Self::Identifier(name) => folder.fold_identifier(name, span), + Self::String(_) => Ok(self), + Self::Pattern(pattern) => folder.fold_pattern(pattern, span), + Self::RemoteSymbol(_) + | ExpressionKind::AtWorkspace(_) + | Self::AtCurrentWorkspace + | Self::DagRangeAll + | Self::RangeAll => Ok(self), + Self::Unary(op, arg) => { + let arg = Box::new(folder.fold_expression(*arg)?); + Ok(Self::Unary(op, arg)) + } + Self::Binary(op, lhs, rhs) => { + let lhs = Box::new(folder.fold_expression(*lhs)?); + let rhs = Box::new(folder.fold_expression(*rhs)?); + Ok(Self::Binary(op, lhs, rhs)) + } + Self::UnionAll(nodes) => { + let nodes = dsl_util::fold_expression_nodes(folder, nodes)?; + Ok(Self::UnionAll(nodes)) + } + Self::FunctionCall(function) => folder.fold_function_call(function, span), + Self::AliasExpanded(id, subst) => { + let subst = Box::new(folder.fold_expression(*subst)?); + Ok(Self::AliasExpanded(id, subst)) + } + } + } +} + +impl<'i> AliasExpandableExpression<'i> for ExpressionKind<'i> { + fn identifier(name: &'i str) -> Self { + Self::Identifier(name) + } + + fn pattern(pattern: Box>) -> Self { + Self::Pattern(pattern) + } + + fn function_call(function: Box>) -> Self { + Self::FunctionCall(function) + } + + fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self { + Self::AliasExpanded(id, subst) + } +} + +/// A urnary operation in the Revset language. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum UnaryOp { + /// `~x` + Negate, + /// `::x` + DagRangePre, + /// `x::` + DagRangePost, + /// `..x` + RangePre, + /// `x..` + RangePost, + /// `x-` + Parents, + /// `x+` + Children, +} + +/// A binary operation in the Revset language. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum BinaryOp { + /// `&` + Intersection, + /// `~` + Difference, + /// `::` + DagRange, + /// `..` + Range, +} + +/// A Expression in the Revset language. +pub type ExpressionNode<'i> = dsl_util::ExpressionNode<'i, ExpressionKind<'i>>; +/// A FunctionCall in the Revset language. +pub type FunctionCallNode<'i> = dsl_util::FunctionCallNode<'i, ExpressionKind<'i>>; +/// A Pattern in the Revset language. +pub type PatternNode<'i> = dsl_util::PatternNode<'i, ExpressionKind<'i>>; + +fn union_nodes<'i>(lhs: ExpressionNode<'i>, rhs: ExpressionNode<'i>) -> ExpressionNode<'i> { + let span = lhs.span.start_pos().span(&rhs.span.end_pos()); + let expr = match lhs.kind { + // Flatten "x | y | z" to save recursion stack. Machine-generated query + // might have long chain of unions. + ExpressionKind::UnionAll(mut nodes) => { + nodes.push(rhs); + ExpressionKind::UnionAll(nodes) + } + _ => ExpressionKind::UnionAll(vec![lhs, rhs]), + }; + ExpressionNode::new(expr, span) +} + +/// Parses text into expression tree. No name resolution is made at this stage. +pub fn parse_program(revset_str: &str) -> Result, RevsetParseError> { + let mut pairs = RevsetParser::parse(Rule::program, revset_str)?; + let first = pairs.next().unwrap(); + assert_eq!(first.as_rule(), Rule::expression); + parse_expression_node(first) +} + +fn parse_expression_node(pair: Pair) -> Result { + fn not_prefix_op( + op: &Pair, + similar_op: impl Into, + description: impl Into, + ) -> RevsetParseError { + RevsetParseError::with_span( + RevsetParseErrorKind::NotPrefixOperator { + op: op.as_str().to_owned(), + similar_op: similar_op.into(), + description: description.into(), + }, + op.as_span(), + ) + } + + fn not_postfix_op( + op: &Pair, + similar_op: impl Into, + description: impl Into, + ) -> RevsetParseError { + RevsetParseError::with_span( + RevsetParseErrorKind::NotPostfixOperator { + op: op.as_str().to_owned(), + similar_op: similar_op.into(), + description: description.into(), + }, + op.as_span(), + ) + } + + fn not_infix_op( + op: &Pair, + similar_op: impl Into, + description: impl Into, + ) -> RevsetParseError { + RevsetParseError::with_span( + RevsetParseErrorKind::NotInfixOperator { + op: op.as_str().to_owned(), + similar_op: similar_op.into(), + description: description.into(), + }, + op.as_span(), + ) + } + + static PRATT: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::union_op, Assoc::Left) + | Op::infix(Rule::compat_add_op, Assoc::Left)) + .op(Op::infix(Rule::intersection_op, Assoc::Left) + | Op::infix(Rule::difference_op, Assoc::Left) + | Op::infix(Rule::compat_sub_op, Assoc::Left)) + .op(Op::prefix(Rule::negate_op)) + // Ranges can't be nested without parentheses. Associativity doesn't matter. + .op(Op::infix(Rule::dag_range_op, Assoc::Left) + | Op::infix(Rule::compat_dag_range_op, Assoc::Left) + | Op::infix(Rule::range_op, Assoc::Left)) + .op(Op::prefix(Rule::dag_range_pre_op) + | Op::prefix(Rule::compat_dag_range_pre_op) + | Op::prefix(Rule::range_pre_op)) + .op(Op::postfix(Rule::dag_range_post_op) + | Op::postfix(Rule::compat_dag_range_post_op) + | Op::postfix(Rule::range_post_op)) + // Neighbors + .op(Op::postfix(Rule::parents_op) + | Op::postfix(Rule::children_op) + | Op::postfix(Rule::compat_parents_op)) + }); + PRATT + .map_primary(|primary| { + let expr = match primary.as_rule() { + Rule::primary => return parse_primary_node(primary), + Rule::dag_range_all_op => ExpressionKind::DagRangeAll, + Rule::range_all_op => ExpressionKind::RangeAll, + r => panic!("unexpected primary rule {r:?}"), + }; + Ok(ExpressionNode::new(expr, primary.as_span())) + }) + .map_prefix(|op, rhs| { + let op_kind = match op.as_rule() { + Rule::negate_op => UnaryOp::Negate, + Rule::dag_range_pre_op => UnaryOp::DagRangePre, + Rule::compat_dag_range_pre_op => Err(not_prefix_op(&op, "::", "ancestors"))?, + Rule::range_pre_op => UnaryOp::RangePre, + r => panic!("unexpected prefix operator rule {r:?}"), + }; + let rhs = Box::new(rhs?); + let span = op.as_span().start_pos().span(&rhs.span.end_pos()); + let expr = ExpressionKind::Unary(op_kind, rhs); + Ok(ExpressionNode::new(expr, span)) + }) + .map_postfix(|lhs, op| { + let op_kind = match op.as_rule() { + Rule::dag_range_post_op => UnaryOp::DagRangePost, + Rule::compat_dag_range_post_op => Err(not_postfix_op(&op, "::", "descendants"))?, + Rule::range_post_op => UnaryOp::RangePost, + Rule::parents_op => UnaryOp::Parents, + Rule::children_op => UnaryOp::Children, + Rule::compat_parents_op => Err(not_postfix_op(&op, "-", "parents"))?, + r => panic!("unexpected postfix operator rule {r:?}"), + }; + let lhs = Box::new(lhs?); + let span = lhs.span.start_pos().span(&op.as_span().end_pos()); + let expr = ExpressionKind::Unary(op_kind, lhs); + Ok(ExpressionNode::new(expr, span)) + }) + .map_infix(|lhs, op, rhs| { + let op_kind = match op.as_rule() { + Rule::union_op => return Ok(union_nodes(lhs?, rhs?)), + Rule::compat_add_op => Err(not_infix_op(&op, "|", "union"))?, + Rule::intersection_op => BinaryOp::Intersection, + Rule::difference_op => BinaryOp::Difference, + Rule::compat_sub_op => Err(not_infix_op(&op, "~", "difference"))?, + Rule::dag_range_op => BinaryOp::DagRange, + Rule::compat_dag_range_op => Err(not_infix_op(&op, "::", "DAG range"))?, + Rule::range_op => BinaryOp::Range, + r => panic!("unexpected infix operator rule {r:?}"), + }; + let lhs = Box::new(lhs?); + let rhs = Box::new(rhs?); + let span = lhs.span.start_pos().span(&rhs.span.end_pos()); + let expr = ExpressionKind::Binary(op_kind, lhs, rhs); + Ok(ExpressionNode::new(expr, span)) + }) + .parse(pair.into_inner()) +} + +fn parse_primary_node(pair: Pair) -> Result { + let span = pair.as_span(); + let mut pairs = pair.into_inner(); + let first = pairs.next().unwrap(); + let expr = match first.as_rule() { + // Ignore inner span to preserve parenthesized expression as such. + Rule::expression => parse_expression_node(first)?.kind, + Rule::function => { + let function = Box::new(FUNCTION_CALL_PARSER.parse( + first, + |pair| Ok(pair.as_str()), + |pair| parse_expression_node(pair), + )?); + ExpressionKind::FunctionCall(function) + } + Rule::pattern => { + let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); + assert_eq!(lhs.as_rule(), Rule::strict_identifier); + assert_eq!(op.as_rule(), Rule::pattern_kind_op); + assert_eq!(rhs.as_rule(), Rule::pattern_value_expression); + let pattern = Box::new(PatternNode { + name: lhs.as_str(), + name_span: lhs.as_span(), + value: parse_expression_node(rhs)?, + }); + ExpressionKind::Pattern(pattern) + } + // Identifier without "@" may be substituted by aliases. Primary expression including "@" + // is considered an indecomposable unit, and no alias substitution would be made. + Rule::identifier if pairs.peek().is_none() => ExpressionKind::Identifier(first.as_str()), + Rule::identifier | Rule::string_literal | Rule::raw_string_literal => { + let name = parse_as_string_literal(first); + match pairs.next() { + None => ExpressionKind::String(name), + Some(op) => { + assert_eq!(op.as_rule(), Rule::at_op); + match pairs.next() { + // postfix "@" + None => ExpressionKind::AtWorkspace(name), + // infix "@" + Some(second) => { + let name: RefNameBuf = name.into(); + let remote: RemoteNameBuf = parse_as_string_literal(second).into(); + ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { name, remote }) + } + } + } + } + } + // nullary "@" + Rule::at_op => ExpressionKind::AtCurrentWorkspace, + r => panic!("unexpected revset parse rule: {r:?}"), + }; + Ok(ExpressionNode::new(expr, span)) +} + +/// Parses part of compound symbol to string. +fn parse_as_string_literal(pair: Pair) -> String { + match pair.as_rule() { + Rule::identifier => pair.as_str().to_owned(), + Rule::string_literal => STRING_LITERAL_PARSER.parse(pair.into_inner()), + Rule::raw_string_literal => { + let [content] = pair.into_inner().collect_array().unwrap(); + assert_eq!(content.as_rule(), Rule::raw_string_content); + content.as_str().to_owned() + } + _ => { + panic!("unexpected string literal rule: {:?}", pair.as_str()); + } + } +} + +/// Checks if the text is a valid identifier +pub fn is_identifier(text: &str) -> bool { + match RevsetParser::parse(Rule::identifier, text) { + Ok(mut pairs) => pairs.next().unwrap().as_span().end() == text.len(), + Err(_) => false, + } +} + +/// Parses the text as a revset symbol, rejects empty string. +pub fn parse_symbol(text: &str) -> Result { + let mut pairs = RevsetParser::parse(Rule::symbol_name, text)?; + let first = pairs.next().unwrap(); + let span = first.as_span(); + let name = parse_as_string_literal(first); + if name.is_empty() { + Err(RevsetParseError::expression( + "Expected non-empty string", + span, + )) + } else { + Ok(name) + } +} + +/// A map of Revset Aliases, usually defined in the `[revset-aliases]` toml +/// table. +pub type RevsetAliasesMap = AliasesMap; + +/// A RevsetAliasesParser is responsible for parsing String expressions into +/// Revset Aliases. +#[derive(Clone, Debug, Default)] +pub struct RevsetAliasParser; + +impl AliasDeclarationParser for RevsetAliasParser { + type Error = RevsetParseError; + + fn parse_declaration(&self, source: &str) -> Result { + let mut pairs = RevsetParser::parse(Rule::alias_declaration, source)?; + let first = pairs.next().unwrap(); + match first.as_rule() { + Rule::strict_identifier => Ok(AliasDeclaration::Symbol(first.as_str().to_owned())), + Rule::pattern_alias_declaration => { + let [name_pair, op, param_pair] = first.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), Rule::strict_identifier); + assert_eq!(op.as_rule(), Rule::pattern_kind_op); + assert_eq!(param_pair.as_rule(), Rule::strict_identifier); + let name = name_pair.as_str().to_owned(); + let param = param_pair.as_str().to_owned(); + Ok(AliasDeclaration::Pattern(name, param)) + } + Rule::function_alias_declaration => { + let [name_pair, params_pair] = first.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), Rule::function_name); + assert_eq!(params_pair.as_rule(), Rule::formal_parameters); + let name = name_pair.as_str().to_owned(); + let params_span = params_pair.as_span(); + let params = params_pair + .into_inner() + .map(|pair| match pair.as_rule() { + Rule::strict_identifier => pair.as_str().to_owned(), + r => panic!("unexpected formal parameter rule {r:?}"), + }) + .collect_vec(); + if params.iter().all_unique() { + Ok(AliasDeclaration::Function(name, params)) + } else { + Err(RevsetParseError::with_span( + RevsetParseErrorKind::RedefinedFunctionParameter, + params_span, + )) + } + } + r => panic!("unexpected alias declaration rule {r:?}"), + } + } +} + +impl AliasDefinitionParser for RevsetAliasParser { + type Output<'i> = ExpressionKind<'i>; + type Error = RevsetParseError; + + fn parse_definition<'i>(&self, source: &'i str) -> Result, Self::Error> { + parse_program(source) + } +} + +/// Expect a `StringPattern` of `type_name` in `node`. +/// Returns the parsed string and optionally the pattern name on success. +pub fn expect_string_pattern<'a>( + type_name: &str, + node: &'a ExpressionNode<'_>, +) -> Result<(&'a str, Option<&'a str>), RevsetParseError> { + catch_aliases_no_diagnostics(node, |node| match &node.kind { + ExpressionKind::Identifier(name) => Ok((*name, None)), + ExpressionKind::String(name) => Ok((name, None)), + ExpressionKind::Pattern(pattern) => { + let value = expect_string_literal("string", &pattern.value)?; + Ok((value, Some(pattern.name))) + } + _ => Err(RevsetParseError::expression( + format!("Expected {type_name}"), + node.span, + )), + }) +} + +/// Expect a literal in `node` with the given `type_name`. +pub fn expect_literal( + type_name: &str, + node: &ExpressionNode, +) -> Result { + catch_aliases_no_diagnostics(node, |node| { + let value = expect_string_literal(type_name, node)?; + value + .parse() + .map_err(|_| RevsetParseError::expression(format!("Expected {type_name}"), node.span)) + }) +} + +/// Expect a String literal in `node` with the given `type_name`. +pub fn expect_string_literal<'a>( + type_name: &str, + node: &'a ExpressionNode<'_>, +) -> Result<&'a str, RevsetParseError> { + catch_aliases_no_diagnostics(node, |node| match &node.kind { + ExpressionKind::Identifier(name) => Ok(*name), + ExpressionKind::String(name) => Ok(name), + _ => Err(RevsetParseError::expression( + format!("Expected {type_name}"), + node.span, + )), + }) +} + +/// Applies the given function to the innermost `node` by unwrapping alias +/// expansion nodes. Appends alias expansion stack to error and diagnostics. +pub fn catch_aliases<'a, 'i, T>( + diagnostics: &mut RevsetDiagnostics, + node: &'a ExpressionNode<'i>, + f: impl FnOnce(&mut RevsetDiagnostics, &'a ExpressionNode<'i>) -> Result, +) -> Result { + let (node, stack) = skip_aliases(node); + if stack.is_empty() { + f(diagnostics, node) + } else { + let mut inner_diagnostics = RevsetDiagnostics::new(); + let result = f(&mut inner_diagnostics, node); + diagnostics.extend_with(inner_diagnostics, |diag| attach_aliases_err(diag, &stack)); + result.map_err(|err| attach_aliases_err(err, &stack)) + } +} + +fn catch_aliases_no_diagnostics<'a, 'i, T>( + node: &'a ExpressionNode<'i>, + f: impl FnOnce(&'a ExpressionNode<'i>) -> Result, +) -> Result { + let (node, stack) = skip_aliases(node); + f(node).map_err(|err| attach_aliases_err(err, &stack)) +} + +fn skip_aliases<'a, 'i>( + mut node: &'a ExpressionNode<'i>, +) -> (&'a ExpressionNode<'i>, Vec<(AliasId<'i>, pest::Span<'i>)>) { + let mut stack = Vec::new(); + while let ExpressionKind::AliasExpanded(id, subst) = &node.kind { + stack.push((*id, node.span)); + node = subst; + } + (node, stack) +} + +fn attach_aliases_err( + err: RevsetParseError, + stack: &[(AliasId<'_>, pest::Span<'_>)], +) -> RevsetParseError { + stack + .iter() + .rfold(err, |err, &(id, span)| err.within_alias_expansion(id, span)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use assert_matches::assert_matches; + + use super::*; + use crate::dsl_util::KeywordArgument; + use crate::tests::TestResult; + + #[derive(Debug)] + struct WithRevsetAliasesMap<'i> { + aliases_map: RevsetAliasesMap, + locals: HashMap<&'i str, ExpressionNode<'i>>, + } + + impl<'i> WithRevsetAliasesMap<'i> { + fn set_local(mut self, name: &'i str, value: &'i str) -> Self { + self.locals.insert(name, parse_program(value).unwrap()); + self + } + + fn parse(&'i self, text: &'i str) -> Result, RevsetParseError> { + let node = parse_program(text)?; + dsl_util::expand_aliases_with_locals(node, &self.aliases_map, &self.locals) + } + + fn parse_normalized(&'i self, text: &'i str) -> ExpressionNode<'i> { + normalize_tree(self.parse(text).unwrap()) + } + } + + fn with_aliases<'i>( + aliases: impl IntoIterator, impl Into)>, + ) -> WithRevsetAliasesMap<'i> { + let mut aliases_map = RevsetAliasesMap::new(); + for (decl, defn) in aliases { + aliases_map.insert(decl, defn, None).unwrap(); + } + WithRevsetAliasesMap { + aliases_map, + locals: HashMap::new(), + } + } + + fn parse_into_kind(text: &str) -> Result, RevsetParseErrorKind> { + parse_program(text) + .map(|node| node.kind) + .map_err(|err| *err.kind) + } + + fn parse_normalized(text: &str) -> ExpressionNode<'_> { + normalize_tree(parse_program(text).unwrap()) + } + + /// Drops auxiliary data from parsed tree so it can be compared with other. + fn normalize_tree(node: ExpressionNode) -> ExpressionNode { + fn empty_span() -> pest::Span<'static> { + pest::Span::new("", 0, 0).unwrap() + } + + fn normalize_list(nodes: Vec) -> Vec { + nodes.into_iter().map(normalize_tree).collect() + } + + fn normalize_function_call(function: FunctionCallNode) -> FunctionCallNode { + FunctionCallNode { + name: function.name, + name_span: empty_span(), + args: normalize_list(function.args), + keyword_args: function + .keyword_args + .into_iter() + .map(|arg| KeywordArgument { + name: arg.name, + name_span: empty_span(), + value: normalize_tree(arg.value), + }) + .collect(), + args_span: empty_span(), + } + } + + let normalized_kind = match node.kind { + ExpressionKind::Identifier(_) | ExpressionKind::String(_) => node.kind, + ExpressionKind::Pattern(pattern) => { + let pattern = Box::new(PatternNode { + name: pattern.name, + name_span: empty_span(), + value: normalize_tree(pattern.value), + }); + ExpressionKind::Pattern(pattern) + } + ExpressionKind::RemoteSymbol(_) + | ExpressionKind::AtWorkspace(_) + | ExpressionKind::AtCurrentWorkspace + | ExpressionKind::DagRangeAll + | ExpressionKind::RangeAll => node.kind, + ExpressionKind::Unary(op, arg) => { + let arg = Box::new(normalize_tree(*arg)); + ExpressionKind::Unary(op, arg) + } + ExpressionKind::Binary(op, lhs, rhs) => { + let lhs = Box::new(normalize_tree(*lhs)); + let rhs = Box::new(normalize_tree(*rhs)); + ExpressionKind::Binary(op, lhs, rhs) + } + ExpressionKind::UnionAll(nodes) => { + let nodes = normalize_list(nodes); + ExpressionKind::UnionAll(nodes) + } + ExpressionKind::FunctionCall(function) => { + let function = Box::new(normalize_function_call(*function)); + ExpressionKind::FunctionCall(function) + } + ExpressionKind::AliasExpanded(_, subst) => normalize_tree(*subst).kind, + }; + ExpressionNode { + kind: normalized_kind, + span: empty_span(), + } + } + + #[test] + fn test_parse_tree_eq() { + assert_eq!( + parse_normalized(r#" foo( x ) | ~bar:"baz" "#), + parse_normalized(r#"(foo(x))|(~(bar:"baz"))"#) + ); + assert_ne!(parse_normalized(r#" foo "#), parse_normalized(r#" "foo" "#)); + } + + #[test] + fn test_parse_revset() -> TestResult { + // Parse a quoted symbol + assert_eq!( + parse_into_kind("\"foo\""), + Ok(ExpressionKind::String("foo".to_owned())) + ); + assert_eq!( + parse_into_kind("'foo'"), + Ok(ExpressionKind::String("foo".to_owned())) + ); + // Parse the "parents" operator + assert_matches!( + parse_into_kind("foo-"), + Ok(ExpressionKind::Unary(UnaryOp::Parents, _)) + ); + // Parse the "children" operator + assert_matches!( + parse_into_kind("foo+"), + Ok(ExpressionKind::Unary(UnaryOp::Children, _)) + ); + // Parse the "ancestors" operator + assert_matches!( + parse_into_kind("::foo"), + Ok(ExpressionKind::Unary(UnaryOp::DagRangePre, _)) + ); + // Parse the "descendants" operator + assert_matches!( + parse_into_kind("foo::"), + Ok(ExpressionKind::Unary(UnaryOp::DagRangePost, _)) + ); + // Parse the "dag range" operator + assert_matches!( + parse_into_kind("foo::bar"), + Ok(ExpressionKind::Binary(BinaryOp::DagRange, _, _)) + ); + // Parse the nullary "dag range" operator + assert_matches!(parse_into_kind("::"), Ok(ExpressionKind::DagRangeAll)); + // Parse the "range" prefix operator + assert_matches!( + parse_into_kind("..foo"), + Ok(ExpressionKind::Unary(UnaryOp::RangePre, _)) + ); + assert_matches!( + parse_into_kind("foo.."), + Ok(ExpressionKind::Unary(UnaryOp::RangePost, _)) + ); + assert_matches!( + parse_into_kind("foo..bar"), + Ok(ExpressionKind::Binary(BinaryOp::Range, _, _)) + ); + // Parse the nullary "range" operator + assert_matches!(parse_into_kind(".."), Ok(ExpressionKind::RangeAll)); + // Parse the "negate" operator + assert_matches!( + parse_into_kind("~ foo"), + Ok(ExpressionKind::Unary(UnaryOp::Negate, _)) + ); + assert_eq!( + parse_normalized("~ ~~ foo"), + parse_normalized("~(~(~(foo)))"), + ); + // Parse the "intersection" operator + assert_matches!( + parse_into_kind("foo & bar"), + Ok(ExpressionKind::Binary(BinaryOp::Intersection, _, _)) + ); + // Parse the "union" operator + assert_matches!( + parse_into_kind("foo | bar"), + Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 2 + ); + assert_matches!( + parse_into_kind("foo | bar | baz"), + Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 3 + ); + // Parse the "difference" operator + assert_matches!( + parse_into_kind("foo ~ bar"), + Ok(ExpressionKind::Binary(BinaryOp::Difference, _, _)) + ); + // Parentheses are allowed before suffix operators + assert_eq!(parse_normalized("(foo)-"), parse_normalized("foo-")); + // Space is allowed around expressions + assert_eq!(parse_normalized(" ::foo "), parse_normalized("::foo")); + assert_eq!(parse_normalized("( ::foo )"), parse_normalized("::foo")); + // Space is not allowed around prefix operators + assert_eq!( + parse_into_kind(" :: foo "), + Err(RevsetParseErrorKind::SyntaxError) + ); + // Incomplete parse + assert_eq!( + parse_into_kind("foo | -"), + Err(RevsetParseErrorKind::SyntaxError) + ); + + // Expression span + assert_eq!(parse_program(" ~ x ")?.span.as_str(), "~ x"); + assert_eq!(parse_program(" x+ ")?.span.as_str(), "x+"); + assert_eq!(parse_program(" x |y ")?.span.as_str(), "x |y"); + assert_eq!(parse_program(" (x) ")?.span.as_str(), "(x)"); + assert_eq!(parse_program("~( x|y) ")?.span.as_str(), "~( x|y)"); + assert_eq!(parse_program(" ( x )- ")?.span.as_str(), "( x )-"); + Ok(()) + } + + #[test] + fn test_parse_whitespace() { + let ascii_whitespaces: String = ('\x00'..='\x7f') + .filter(char::is_ascii_whitespace) + .collect(); + assert_eq!( + parse_normalized(&format!("{ascii_whitespaces}all()")), + parse_normalized("all()"), + ); + } + + #[test] + fn test_parse_identifier() { + // Integer is a symbol + assert_eq!(parse_into_kind("0"), Ok(ExpressionKind::Identifier("0"))); + // Tag/bookmark name separated by / + assert_eq!( + parse_into_kind("foo_bar/baz"), + Ok(ExpressionKind::Identifier("foo_bar/baz")) + ); + // Glob literal with star + assert_eq!( + parse_into_kind("*/foo/**"), + Ok(ExpressionKind::Identifier("*/foo/**")) + ); + + // Internal '.', '-', and '+' are allowed + assert_eq!( + parse_into_kind("foo.bar-v1+7"), + Ok(ExpressionKind::Identifier("foo.bar-v1+7")) + ); + assert_eq!( + parse_normalized("foo.bar-v1+7-"), + parse_normalized("(foo.bar-v1+7)-") + ); + // Multiple '-' are allowed + assert_eq!( + parse_into_kind("foo--bar"), + Ok(ExpressionKind::Identifier("foo--bar")) + ); + assert_eq!( + parse_into_kind("foo----bar"), + Ok(ExpressionKind::Identifier("foo----bar")) + ); + // '.' is not allowed at the beginning or end + assert_eq!( + parse_into_kind(".foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo."), + Err(RevsetParseErrorKind::SyntaxError) + ); + // Multiple '.' and '+', or together with '-', are not allowed + assert_eq!( + parse_into_kind("foo.+bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo++bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo+-bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + + // Parse a parenthesized symbol + assert_eq!(parse_normalized("(foo)"), parse_normalized("foo")); + + // Non-ASCII tag/bookmark name + assert_eq!( + parse_into_kind("柔術+jj"), + Ok(ExpressionKind::Identifier("柔術+jj")) + ); + } + + #[test] + fn test_parse_string_literal() { + // "\" escapes + assert_eq!( + parse_into_kind(r#" "\t\r\n\"\\\0\e" "#), + Ok(ExpressionKind::String("\t\r\n\"\\\0\u{1b}".to_owned())) + ); + + // Invalid "\" escape + assert_eq!( + parse_into_kind(r#" "\y" "#), + Err(RevsetParseErrorKind::SyntaxError) + ); + + // Single-quoted raw string + assert_eq!( + parse_into_kind(r#" '' "#), + Ok(ExpressionKind::String("".to_owned())) + ); + assert_eq!( + parse_into_kind(r#" 'a\n' "#), + Ok(ExpressionKind::String(r"a\n".to_owned())) + ); + assert_eq!( + parse_into_kind(r#" '\' "#), + Ok(ExpressionKind::String(r"\".to_owned())) + ); + assert_eq!( + parse_into_kind(r#" '"' "#), + Ok(ExpressionKind::String(r#"""#.to_owned())) + ); + + // Hex bytes + assert_eq!( + parse_into_kind(r#""\x61\x65\x69\x6f\x75""#), + Ok(ExpressionKind::String("aeiou".to_owned())) + ); + assert_eq!( + parse_into_kind(r#""\xe0\xe8\xec\xf0\xf9""#), + Ok(ExpressionKind::String("àèìðù".to_owned())) + ); + assert_eq!( + parse_into_kind(r#""\x""#), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind(r#""\xf""#), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind(r#""\xgg""#), + Err(RevsetParseErrorKind::SyntaxError) + ); + } + + #[test] + fn test_parse_pattern() -> TestResult { + fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { + match kind { + ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), + _ => panic!("unexpected expression: {kind:?}"), + } + } + + assert_eq!( + unwrap_pattern(parse_into_kind(r#"substring:"foo""#)?), + ("substring", ExpressionKind::String("foo".to_owned())) + ); + assert_eq!( + unwrap_pattern(parse_into_kind("exact:foo")?), + ("exact", ExpressionKind::Identifier("foo")) + ); + assert_eq!( + parse_into_kind(r#""exact:foo""#), + Ok(ExpressionKind::String("exact:foo".to_owned())) + ); + // Symbol-like value expressions + assert_eq!( + unwrap_pattern(parse_into_kind("x:@")?), + ("x", ExpressionKind::AtCurrentWorkspace) + ); + assert_eq!( + unwrap_pattern(parse_into_kind("x:y@z")?), + ( + "x", + ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "y".into(), + remote: "z".into(), + }) + ) + ); + + assert_eq!( + parse_normalized(r#"(exact:"foo" )"#), + parse_normalized(r#"(exact:"foo")"#), + ); + assert_eq!( + unwrap_pattern(parse_into_kind(r#"exact:'\'"#)?), + ("exact", ExpressionKind::String(r"\".to_owned())) + ); + + // Whitespace isn't allowed in between + assert_matches!( + parse_into_kind("exact: foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_matches!( + parse_into_kind("exact :foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + // Whitespace is allowed in parenthesized value expression + assert_eq!( + parse_normalized("exact:( 'foo' )"), + parse_normalized("exact:'foo'"), + ); + + // Functions are allowed + assert_eq!(parse_normalized("x:f(y)"), parse_normalized("x:(f(y))")); + // Neighbor postfix operations are also allowed + assert_eq!(parse_normalized("x:@-+"), parse_normalized("x:((@-)+)")); + // Ranges have lower binding strength because we wouldn't want to parse + // x::: as x:(::) + assert_eq!(parse_normalized("x:y::z"), parse_normalized("(x:y)::(z)")); + assert_matches!( + parse_into_kind("x:::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + // Logical operators have lower binding strength + assert_eq!(parse_normalized("x:y&z"), parse_normalized("(x:y)&(z)")); + assert_matches!( + parse_into_kind("x:~y"), // (x:) ~ (y) + Err(RevsetParseErrorKind::NotPostfixOperator { .. }) + ); + + // Pattern prefix is like (type)x cast, so is evaluated from right + assert_eq!(parse_normalized("x:y:z"), parse_normalized("x:(y:z)")); + Ok(()) + } + + #[test] + fn test_parse_symbol_explicitly() { + assert_matches!(parse_symbol("").as_deref(), Err(_)); + // empty string could be a valid ref name, but it would be super + // confusing if identifier was empty. + assert_matches!(parse_symbol("''").as_deref(), Err(_)); + + assert_matches!(parse_symbol("foo.bar").as_deref(), Ok("foo.bar")); + assert_matches!(parse_symbol("foo@bar").as_deref(), Err(_)); + assert_matches!(parse_symbol("foo bar").as_deref(), Err(_)); + + assert_matches!(parse_symbol("'foo bar'").as_deref(), Ok("foo bar")); + assert_matches!(parse_symbol(r#""foo\tbar""#).as_deref(), Ok("foo\tbar")); + + // leading/trailing whitespace is NOT ignored. + assert_matches!(parse_symbol(" foo").as_deref(), Err(_)); + assert_matches!(parse_symbol("foo ").as_deref(), Err(_)); + + // (foo) could be parsed as a symbol "foo", but is rejected because user + // might expect a literal "(foo)". + assert_matches!(parse_symbol("(foo)").as_deref(), Err(_)); + } + + #[test] + fn parse_at_workspace_and_remote_symbol() { + // Parse "@" (the current working copy) + assert_eq!(parse_into_kind("@"), Ok(ExpressionKind::AtCurrentWorkspace)); + assert_eq!( + parse_into_kind("main@"), + Ok(ExpressionKind::AtWorkspace("main".to_owned())) + ); + assert_eq!( + parse_into_kind("main@origin"), + Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "main".into(), + remote: "origin".into() + })) + ); + + // Quoted component in @ expression + assert_eq!( + parse_into_kind(r#""foo bar"@"#), + Ok(ExpressionKind::AtWorkspace("foo bar".to_owned())) + ); + assert_eq!( + parse_into_kind(r#""foo bar"@origin"#), + Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "foo bar".into(), + remote: "origin".into() + })) + ); + assert_eq!( + parse_into_kind(r#"main@"foo bar""#), + Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "main".into(), + remote: "foo bar".into() + })) + ); + assert_eq!( + parse_into_kind(r#"'foo bar'@'bar baz'"#), + Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "foo bar".into(), + remote: "bar baz".into() + })) + ); + + // Quoted "@" is not interpreted as a working copy or remote symbol + assert_eq!( + parse_into_kind(r#""@""#), + Ok(ExpressionKind::String("@".to_owned())) + ); + assert_eq!( + parse_into_kind(r#""main@""#), + Ok(ExpressionKind::String("main@".to_owned())) + ); + assert_eq!( + parse_into_kind(r#""main@origin""#), + Ok(ExpressionKind::String("main@origin".to_owned())) + ); + + // Non-ASCII name + assert_eq!( + parse_into_kind("柔術@"), + Ok(ExpressionKind::AtWorkspace("柔術".to_owned())) + ); + assert_eq!( + parse_into_kind("柔@術"), + Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { + name: "柔".into(), + remote: "術".into() + })) + ); + } + + #[test] + fn test_parse_function_call() -> TestResult { + fn unwrap_function_call(node: ExpressionNode<'_>) -> Box> { + match node.kind { + ExpressionKind::FunctionCall(function) => function, + _ => panic!("unexpected expression: {node:?}"), + } + } + + // Space is allowed around infix operators and function arguments + assert_eq!( + parse_normalized( + " description( arg1 ) ~ file( arg1 , arg2 ) ~ visible_heads( ) ", + ), + parse_normalized("(description(arg1) ~ file(arg1, arg2)) ~ visible_heads()"), + ); + // Space is allowed around keyword arguments + assert_eq!( + parse_normalized("remote_bookmarks( remote = foo )"), + parse_normalized("remote_bookmarks(remote=foo)"), + ); + + // Trailing comma isn't allowed for empty argument + assert!(parse_into_kind("bookmarks(,)").is_err()); + // Trailing comma is allowed for the last argument + assert_eq!( + parse_normalized("bookmarks(a,)"), + parse_normalized("bookmarks(a)") + ); + assert_eq!( + parse_normalized("bookmarks(a , )"), + parse_normalized("bookmarks(a)") + ); + assert!(parse_into_kind("bookmarks(,a)").is_err()); + assert!(parse_into_kind("bookmarks(a,,)").is_err()); + assert!(parse_into_kind("bookmarks(a , , )").is_err()); + assert_eq!( + parse_normalized("file(a,b,)"), + parse_normalized("file(a, b)") + ); + assert!(parse_into_kind("file(a,,b)").is_err()); + assert_eq!( + parse_normalized("remote_bookmarks(a,remote=b , )"), + parse_normalized("remote_bookmarks(a, remote=b)"), + ); + assert!(parse_into_kind("remote_bookmarks(a,,remote=b)").is_err()); + + // Expression span + let function = unwrap_function_call(parse_program("foo( a, (b) , ~(c), d = (e) )")?); + assert_eq!(function.name_span.as_str(), "foo"); + assert_eq!(function.args_span.as_str(), "a, (b) , ~(c), d = (e)"); + assert_eq!(function.args[0].span.as_str(), "a"); + assert_eq!(function.args[1].span.as_str(), "(b)"); + assert_eq!(function.args[2].span.as_str(), "~(c)"); + assert_eq!(function.keyword_args[0].name_span.as_str(), "d"); + assert_eq!(function.keyword_args[0].value.span.as_str(), "(e)"); + Ok(()) + } + + #[test] + fn test_parse_revset_alias_symbol_decl() { + let mut aliases_map = RevsetAliasesMap::new(); + // Working copy or remote symbol cannot be used as an alias name. + assert!(aliases_map.insert("@", "none()", None).is_err()); + assert!(aliases_map.insert("a@", "none()", None).is_err()); + assert!(aliases_map.insert("a@b", "none()", None).is_err()); + // Non-ASCII character isn't allowed in alias symbol. This rule can be + // relaxed if needed. + assert!(aliases_map.insert("柔術", "none()", None).is_err()); + } + + #[test] + fn test_parse_revset_alias_pattern_decl() -> TestResult { + let mut aliases_map = RevsetAliasesMap::new(); + assert!(aliases_map.insert("foo:", "none()", None).is_err()); + assert_eq!(aliases_map.pattern_names().count(), 0); + + aliases_map.insert("bar:baz", "'bar pattern'", None)?; + assert_eq!(aliases_map.pattern_names().count(), 1); + let (id, param, defn, _doc) = aliases_map.get_pattern("bar").unwrap(); + assert_eq!(id, AliasId::Pattern("bar", "baz")); + assert_eq!(param, "baz"); + assert_eq!(defn, "'bar pattern'"); + + // Non-ASCII character isn't allowed. This rule can be relaxed if + // needed. + assert!(aliases_map.insert("柔術:x", "none()", None).is_err()); + assert!(aliases_map.insert("x:柔術", "none()", None).is_err()); + Ok(()) + } + + #[test] + fn test_parse_revset_alias_func_decl() -> TestResult { + let mut aliases_map = RevsetAliasesMap::new(); + assert!( + aliases_map + .insert("5func()", r#""is function 0""#, None) + .is_err() + ); + aliases_map.insert("func()", r#""is function 0""#, None)?; + aliases_map.insert("func(a, b)", r#""is function 2""#, None)?; + aliases_map.insert("func(a)", r#""is function a""#, None)?; + aliases_map.insert("func(b)", r#""is function b""#, None)?; + + let (id, params, defn, _doc) = aliases_map.get_function("func", 0).unwrap(); + assert_eq!(id, AliasId::Function("func", &[])); + assert!(params.is_empty()); + assert_eq!(defn, r#""is function 0""#); + + let (id, params, defn, _doc) = aliases_map.get_function("func", 1).unwrap(); + assert_eq!(id, AliasId::Function("func", &["b".to_owned()])); + assert_eq!(params, ["b"]); + assert_eq!(defn, r#""is function b""#); + + let (id, params, defn, _doc) = aliases_map.get_function("func", 2).unwrap(); + assert_eq!( + id, + AliasId::Function("func", &["a".to_owned(), "b".to_owned()]) + ); + assert_eq!(params, ["a", "b"]); + assert_eq!(defn, r#""is function 2""#); + + assert!(aliases_map.get_function("func", 3).is_none()); + Ok(()) + } + + #[test] + fn test_parse_revset_alias_formal_parameter() { + let mut aliases_map = RevsetAliasesMap::new(); + // Working copy or remote symbol cannot be used as an parameter name. + assert!(aliases_map.insert("f(@)", "none()", None).is_err()); + assert!(aliases_map.insert("f(a@)", "none()", None).is_err()); + assert!(aliases_map.insert("f(a@b)", "none()", None).is_err()); + // Trailing comma isn't allowed for empty parameter + assert!(aliases_map.insert("f(,)", "none()", None).is_err()); + // Trailing comma is allowed for the last parameter + assert!(aliases_map.insert("g(a,)", "none()", None).is_ok()); + assert!(aliases_map.insert("h(a , )", "none()", None).is_ok()); + assert!(aliases_map.insert("i(,a)", "none()", None).is_err()); + assert!(aliases_map.insert("j(a,,)", "none()", None).is_err()); + assert!(aliases_map.insert("k(a , , )", "none()", None).is_err()); + assert!(aliases_map.insert("l(a,b,)", "none()", None).is_ok()); + assert!(aliases_map.insert("m(a,,b)", "none()", None).is_err()); + } + + #[test] + fn test_parse_revset_compat_operator() { + assert_eq!( + parse_into_kind(":foo"), + Err(RevsetParseErrorKind::NotPrefixOperator { + op: ":".to_owned(), + similar_op: "::".to_owned(), + description: "ancestors".to_owned(), + }) + ); + assert_eq!( + parse_into_kind("foo^"), + Err(RevsetParseErrorKind::NotPostfixOperator { + op: "^".to_owned(), + similar_op: "-".to_owned(), + description: "parents".to_owned(), + }) + ); + assert_eq!( + parse_into_kind("foo + bar"), + Err(RevsetParseErrorKind::NotInfixOperator { + op: "+".to_owned(), + similar_op: "|".to_owned(), + description: "union".to_owned(), + }) + ); + assert_eq!( + parse_into_kind("foo - bar"), + Err(RevsetParseErrorKind::NotInfixOperator { + op: "-".to_owned(), + similar_op: "~".to_owned(), + description: "difference".to_owned(), + }) + ); + } + + #[test] + fn test_parse_revset_operator_combinations() { + // Parse repeated "parents" operator + assert_eq!(parse_normalized("foo---"), parse_normalized("((foo-)-)-")); + // Parse repeated "children" operator + assert_eq!(parse_normalized("foo+++"), parse_normalized("((foo+)+)+")); + // Set operator associativity/precedence + assert_eq!(parse_normalized("~x|y"), parse_normalized("(~x)|y")); + assert_eq!(parse_normalized("x&~y"), parse_normalized("x&(~y)")); + assert_eq!(parse_normalized("x~~y"), parse_normalized("x~(~y)")); + assert_eq!(parse_normalized("x~~~y"), parse_normalized("x~(~(~y))")); + assert_eq!(parse_normalized("~x::y"), parse_normalized("~(x::y)")); + assert_eq!(parse_normalized("x|y|z"), parse_normalized("(x|y)|z")); + assert_eq!(parse_normalized("x&y|z"), parse_normalized("(x&y)|z")); + assert_eq!(parse_normalized("x|y&z"), parse_normalized("x|(y&z)")); + assert_eq!(parse_normalized("x|y~z"), parse_normalized("x|(y~z)")); + assert_eq!(parse_normalized("::&.."), parse_normalized("(::)&(..)")); + // Parse repeated "ancestors"/"descendants"/"dag range"/"range" operators + assert_eq!( + parse_into_kind("::foo::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind(":::foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("::::foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo:::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo::::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo:::bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo::::bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("::foo::bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo::bar::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("::::"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("....foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo...."), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo.....bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("..foo..bar"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("foo..bar.."), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("...."), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("::.."), + Err(RevsetParseErrorKind::SyntaxError) + ); + // Parse combinations of "parents"/"children" operators and the range operators. + // The former bind more strongly. + assert_eq!(parse_normalized("foo-+"), parse_normalized("(foo-)+")); + assert_eq!(parse_normalized("foo-::"), parse_normalized("(foo-)::")); + assert_eq!(parse_normalized("::foo+"), parse_normalized("::(foo+)")); + assert_eq!( + parse_into_kind("::-"), + Err(RevsetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind("..+"), + Err(RevsetParseErrorKind::SyntaxError) + ); + } + + #[test] + fn test_parse_revset_function() { + assert_matches!( + parse_into_kind("parents(foo)"), + Ok(ExpressionKind::FunctionCall(_)) + ); + assert_eq!( + parse_normalized("parents((foo))"), + parse_normalized("parents(foo)"), + ); + assert_eq!( + parse_into_kind("parents(foo"), + Err(RevsetParseErrorKind::SyntaxError) + ); + } + + #[test] + fn test_expand_symbol_alias() { + assert_eq!( + with_aliases([("AB", "a&b")]).parse_normalized("AB|c"), + parse_normalized("(a&b)|c") + ); + assert_eq!( + with_aliases([("AB", "a|b")]).parse_normalized("AB::heads(AB)"), + parse_normalized("(a|b)::heads(a|b)") + ); + + // Not string substitution 'a&b|c', but tree substitution. + assert_eq!( + with_aliases([("BC", "b|c")]).parse_normalized("a&BC"), + parse_normalized("a&(b|c)") + ); + + // String literal should not be substituted with alias. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized(r#"A|"A"|'A'"#), + parse_normalized("a|'A'|'A'") + ); + + // Kind of string pattern should not be substituted, which is similar to + // function name. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("author(A:b)"), + parse_normalized("author(A:b)") + ); + + // Value of string pattern can be substituted if it's an identifier. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("author(exact:A)"), + parse_normalized("author(exact:a)") + ); + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("author(exact:'A')"), + parse_normalized("author(exact:'A')") + ); + + // Part of @ symbol cannot be substituted. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("A@"), + parse_normalized("A@") + ); + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("A@b"), + parse_normalized("A@b") + ); + assert_eq!( + with_aliases([("B", "b")]).parse_normalized("a@B"), + parse_normalized("a@B") + ); + + // Multi-level substitution. + assert_eq!( + with_aliases([("A", "BC"), ("BC", "b|C"), ("C", "c")]).parse_normalized("A"), + parse_normalized("b|c") + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + *with_aliases([("A", "A")]).parse("A").unwrap_err().kind, + RevsetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + assert_eq!( + *with_aliases([("A", "B"), ("B", "b|C"), ("C", "c|B")]) + .parse("A") + .unwrap_err() + .kind, + RevsetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + + // Error in alias definition. + assert_eq!( + *with_aliases([("A", "a(")]).parse("A").unwrap_err().kind, + RevsetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + } + + #[test] + fn test_expand_pattern_alias() { + assert_eq!( + with_aliases([("P:x", "x")]).parse_normalized("P:a"), + parse_normalized("a") + ); + + // Argument should be resolved in the current scope. + assert_eq!( + with_aliases([("P:x", "x|a")]).parse_normalized("P:x"), + parse_normalized("x|a") + ); + // P:a -> (Q:a)&y -> (x|a)&y + assert_eq!( + with_aliases([("P:x", "(Q:x)&y"), ("Q:y", "x|y")]).parse_normalized("P:a"), + parse_normalized("(x|a)&y") + ); + + // Pattern parameter should precede the symbol alias. + assert_eq!( + with_aliases([("P:X", "X"), ("X", "x")]).parse_normalized("(P:a)|X"), + parse_normalized("a|x") + ); + + // Pattern parameter shouldn't be expanded in symbol alias. + assert_eq!( + with_aliases([("P:x", "x|A"), ("A", "x")]).parse_normalized("P:a"), + parse_normalized("a|x") + ); + + // String literal should not be substituted with pattern parameter. + assert_eq!( + with_aliases([("P:x", "x|'x'")]).parse_normalized("P:a"), + parse_normalized("a|'x'") + ); + + // Pattern and symbol aliases reside in separate namespaces. + assert_eq!( + with_aliases([("A:x", "A"), ("A", "a")]).parse_normalized("A:x"), + parse_normalized("a") + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + *with_aliases([("P:x", "Q:x"), ("Q:x", "R:x"), ("R:x", "P:x")]) + .parse("P:a") + .unwrap_err() + .kind, + RevsetParseErrorKind::InAliasExpansion("P:x".to_owned()) + ); + } + + #[test] + fn test_expand_function_alias() { + assert_eq!( + with_aliases([("F( )", "a")]).parse_normalized("F()"), + parse_normalized("a") + ); + assert_eq!( + with_aliases([("F( x )", "x")]).parse_normalized("F(a)"), + parse_normalized("a") + ); + assert_eq!( + with_aliases([("F( x, y )", "x|y")]).parse_normalized("F(a, b)"), + parse_normalized("a|b") + ); + + // Not recursion because functions are overloaded by arity. + assert_eq!( + with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "x|y")]).parse_normalized("F(a)"), + parse_normalized("a|b") + ); + + // Arguments should be resolved in the current scope. + assert_eq!( + with_aliases([("F(x,y)", "x|y")]).parse_normalized("F(a::y,b::x)"), + parse_normalized("(a::y)|(b::x)") + ); + // F(a) -> G(a)&y -> (x|a)&y + assert_eq!( + with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(a)"), + parse_normalized("(x|a)&y") + ); + // F(G(a)) -> F(x|a) -> G(x|a)&y -> (x|(x|a))&y + assert_eq!( + with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(G(a))"), + parse_normalized("(x|(x|a))&y") + ); + + // Function parameter should precede the symbol alias. + assert_eq!( + with_aliases([("F(X)", "X"), ("X", "x")]).parse_normalized("F(a)|X"), + parse_normalized("a|x") + ); + + // Function parameter shouldn't be expanded in symbol alias. + assert_eq!( + with_aliases([("F(x)", "x|A"), ("A", "x")]).parse_normalized("F(a)"), + parse_normalized("a|x") + ); + + // String literal should not be substituted with function parameter. + assert_eq!( + with_aliases([("F(x)", r#"x|"x""#)]).parse_normalized("F(a)"), + parse_normalized("a|'x'") + ); + + // Function and symbol aliases reside in separate namespaces. + assert_eq!( + with_aliases([("A()", "A"), ("A", "a")]).parse_normalized("A()"), + parse_normalized("a") + ); + + // Invalid number of arguments. + assert_eq!( + *with_aliases([("F()", "x")]).parse("F(a)").unwrap_err().kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Expected 0 arguments".to_owned() + } + ); + assert_eq!( + *with_aliases([("F(x)", "x")]).parse("F()").unwrap_err().kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Expected 1 arguments".to_owned() + } + ); + assert_eq!( + *with_aliases([("F(x,y)", "x|y")]) + .parse("F(a,b,c)") + .unwrap_err() + .kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Expected 2 arguments".to_owned() + } + ); + assert_eq!( + *with_aliases([("F(x)", "x"), ("F(x,y)", "x|y")]) + .parse("F()") + .unwrap_err() + .kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Expected 1 to 2 arguments".to_owned() + } + ); + assert_eq!( + *with_aliases([("F()", "x"), ("F(x,y)", "x|y")]) + .parse("F(a)") + .unwrap_err() + .kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Expected 0, 2 arguments".to_owned() + } + ); + + // Keyword argument isn't supported for now. + assert_eq!( + *with_aliases([("F(x)", "x")]) + .parse("F(x=y)") + .unwrap_err() + .kind, + RevsetParseErrorKind::InvalidFunctionArguments { + name: "F".to_owned(), + message: "Unexpected keyword arguments".to_owned() + } + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + *with_aliases([("F(x)", "G(x)"), ("G(x)", "H(x)"), ("H(x)", "F(x)")]) + .parse("F(a)") + .unwrap_err() + .kind, + RevsetParseErrorKind::InAliasExpansion("F(x)".to_owned()) + ); + assert_eq!( + *with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "F(x|y)")]) + .parse("F(a)") + .unwrap_err() + .kind, + RevsetParseErrorKind::InAliasExpansion("F(x)".to_owned()) + ); + } + + #[test] + fn test_expand_with_locals() { + // Local variable should precede the symbol alias. + assert_eq!( + with_aliases([("A", "symbol")]) + .set_local("A", "local") + .parse_normalized("A"), + parse_normalized("local") + ); + + // Local variable shouldn't be expanded within aliases. + assert_eq!( + with_aliases([("B", "A"), ("F(x)", "x&A")]) + .set_local("A", "a") + .parse_normalized("A|B|F(A)"), + parse_normalized("a|A|(a&A)") + ); + } +} diff --git a/lib/src/dsl_util.rs b/lib/src/dsl_util.rs index 0bad038f3f9..d09102e41e9 100644 --- a/lib/src/dsl_util.rs +++ b/lib/src/dsl_util.rs @@ -14,1074 +14,26 @@ //! Domain-specific language helpers. -use std::ascii; -use std::collections::HashMap; -use std::fmt; -use std::slice; - -use itertools::Itertools as _; -use pest::RuleType; -use pest::iterators::Pair; -use pest::iterators::Pairs; - -/// Manages diagnostic messages emitted during parsing. -/// -/// `T` is usually a parse error type of the language, which contains a message -/// and source span of 'static lifetime. -#[derive(Debug)] -pub struct Diagnostics { - // This might be extended to [{ kind: Warning|Error, message: T }, ..]. - diagnostics: Vec, -} - -impl Diagnostics { - /// Creates new empty diagnostics collector. - pub fn new() -> Self { - Self { - diagnostics: Vec::new(), - } - } - - /// Returns `true` if there are no diagnostic messages. - pub fn is_empty(&self) -> bool { - self.diagnostics.is_empty() - } - - /// Returns the number of diagnostic messages. - pub fn len(&self) -> usize { - self.diagnostics.len() - } - - /// Returns iterator over diagnostic messages. - pub fn iter(&self) -> slice::Iter<'_, T> { - self.diagnostics.iter() - } - - /// Adds a diagnostic message of warning level. - pub fn add_warning(&mut self, diag: T) { - self.diagnostics.push(diag); - } - - /// Moves diagnostic messages of different type (such as fileset warnings - /// emitted within `file()` revset.) - pub fn extend_with(&mut self, diagnostics: Diagnostics, mut f: impl FnMut(U) -> T) { - self.diagnostics - .extend(diagnostics.diagnostics.into_iter().map(&mut f)); - } -} - -impl Default for Diagnostics { - fn default() -> Self { - Self::new() - } -} - -impl<'a, T> IntoIterator for &'a Diagnostics { - type Item = &'a T; - type IntoIter = slice::Iter<'a, T>; - - fn into_iter(self) -> Self::IntoIter { - self.iter() - } -} - -/// AST node without type or name checking. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ExpressionNode<'i, T> { - /// Expression item such as identifier, literal, function call, etc. - pub kind: T, - /// Span of the node. - pub span: pest::Span<'i>, -} - -impl<'i, T> ExpressionNode<'i, T> { - /// Wraps the given expression and span. - pub fn new(kind: T, span: pest::Span<'i>) -> Self { - Self { kind, span } - } -} - -/// `:` expression in AST. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PatternNode<'i, T> { - /// Pattern name or type (such as `glob`.) - pub name: &'i str, - /// Span of the pattern name. - pub name_span: pest::Span<'i>, - /// Value expression. - pub value: ExpressionNode<'i, T>, -} - -/// Function call in AST. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FunctionCallNode<'i, T> { - /// Function name. - pub name: &'i str, - /// Span of the function name. - pub name_span: pest::Span<'i>, - /// List of positional arguments. - pub args: Vec>, - /// List of keyword arguments. - pub keyword_args: Vec>, - /// Span of the arguments list. - pub args_span: pest::Span<'i>, -} - -/// Keyword argument pair in AST. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct KeywordArgument<'i, T> { - /// Parameter name. - pub name: &'i str, - /// Span of the parameter name. - pub name_span: pest::Span<'i>, - /// Value expression. - pub value: ExpressionNode<'i, T>, -} - -impl<'i, T> FunctionCallNode<'i, T> { - /// Number of arguments assuming named arguments are all unique. - pub fn arity(&self) -> usize { - self.args.len() + self.keyword_args.len() - } - - /// Ensures that no arguments passed. - pub fn expect_no_arguments(&self) -> Result<(), InvalidArguments<'i>> { - let ([], []) = self.expect_arguments()?; - Ok(()) - } - - /// Extracts exactly N required arguments. - pub fn expect_exact_arguments( - &self, - ) -> Result<&[ExpressionNode<'i, T>; N], InvalidArguments<'i>> { - let (args, []) = self.expect_arguments()?; - Ok(args) - } - - /// Extracts N required arguments and remainders. - /// - /// This can be used to get all the positional arguments without requiring - /// any (N = 0): - /// ```ignore - /// let ([], content_nodes) = function.expect_some_arguments()?; - /// ``` - /// Avoid accessing `function.args` directly, as that may allow keyword - /// arguments to be silently ignored. - #[expect(clippy::type_complexity)] - pub fn expect_some_arguments( - &self, - ) -> Result<(&[ExpressionNode<'i, T>; N], &[ExpressionNode<'i, T>]), InvalidArguments<'i>> { - self.ensure_no_keyword_arguments()?; - if self.args.len() >= N { - let (required, rest) = self.args.split_at(N); - Ok((required.try_into().unwrap(), rest)) - } else { - Err(self.invalid_arguments_count(N, None)) - } - } - - /// Extracts N required arguments and M optional arguments. - #[expect(clippy::type_complexity)] - pub fn expect_arguments( - &self, - ) -> Result< - ( - &[ExpressionNode<'i, T>; N], - [Option<&ExpressionNode<'i, T>>; M], - ), - InvalidArguments<'i>, - > { - self.ensure_no_keyword_arguments()?; - let count_range = N..=(N + M); - if count_range.contains(&self.args.len()) { - let (required, rest) = self.args.split_at(N); - let mut optional = rest.iter().map(Some).collect_vec(); - optional.resize(M, None); - Ok(( - required.try_into().unwrap(), - optional.try_into().ok().unwrap(), - )) - } else { - let (min, max) = count_range.into_inner(); - Err(self.invalid_arguments_count(min, Some(max))) - } - } - - /// Extracts N required arguments and M optional arguments. Some of them can - /// be specified as keyword arguments. - /// - /// `names` is a list of parameter names. Unnamed positional arguments - /// should be padded with `""`. - #[expect(clippy::type_complexity)] - pub fn expect_named_arguments( - &self, - names: &[&str], - ) -> Result< - ( - [&ExpressionNode<'i, T>; N], - [Option<&ExpressionNode<'i, T>>; M], - ), - InvalidArguments<'i>, - > { - if self.keyword_args.is_empty() { - let (required, optional) = self.expect_arguments::()?; - Ok((required.each_ref(), optional)) - } else { - let (required, optional) = self.expect_named_arguments_vec(names, N, N + M)?; - Ok(( - required.try_into().ok().unwrap(), - optional.try_into().ok().unwrap(), - )) - } - } - - #[expect(clippy::type_complexity)] - fn expect_named_arguments_vec( - &self, - names: &[&str], - min: usize, - max: usize, - ) -> Result< - ( - Vec<&ExpressionNode<'i, T>>, - Vec>>, - ), - InvalidArguments<'i>, - > { - assert!(names.len() <= max); - - if self.args.len() > max { - return Err(self.invalid_arguments_count(min, Some(max))); - } - let mut extracted = Vec::with_capacity(max); - extracted.extend(self.args.iter().map(Some)); - extracted.resize(max, None); - - for arg in &self.keyword_args { - let name = arg.name; - let span = arg.name_span.start_pos().span(&arg.value.span.end_pos()); - let pos = names.iter().position(|&n| n == name).ok_or_else(|| { - self.invalid_arguments(format!(r#"Unexpected keyword argument "{name}""#), span) - })?; - if extracted[pos].is_some() { - return Err(self.invalid_arguments( - format!(r#"Got multiple values for keyword "{name}""#), - span, - )); - } - extracted[pos] = Some(&arg.value); - } - - let optional = extracted.split_off(min); - let required = extracted.into_iter().flatten().collect_vec(); - if required.len() != min { - return Err(self.invalid_arguments_count(min, Some(max))); - } - Ok((required, optional)) - } - - fn ensure_no_keyword_arguments(&self) -> Result<(), InvalidArguments<'i>> { - if let (Some(first), Some(last)) = (self.keyword_args.first(), self.keyword_args.last()) { - let span = first.name_span.start_pos().span(&last.value.span.end_pos()); - Err(self.invalid_arguments("Unexpected keyword arguments".to_owned(), span)) - } else { - Ok(()) - } - } - - fn invalid_arguments(&self, message: String, span: pest::Span<'i>) -> InvalidArguments<'i> { - InvalidArguments { - name: self.name, - message, - span, - } - } - - fn invalid_arguments_count(&self, min: usize, max: Option) -> InvalidArguments<'i> { - let message = match (min, max) { - (min, Some(max)) if min == max => format!("Expected {min} arguments"), - (min, Some(max)) => format!("Expected {min} to {max} arguments"), - (min, None) => format!("Expected at least {min} arguments"), - }; - self.invalid_arguments(message, self.args_span) - } - - fn invalid_arguments_count_with_arities( - &self, - arities: impl IntoIterator, - ) -> InvalidArguments<'i> { - let message = format!("Expected {} arguments", arities.into_iter().join(", ")); - self.invalid_arguments(message, self.args_span) - } -} - -/// Unexpected number of arguments, or invalid combination of arguments. -/// -/// This error is supposed to be converted to language-specific parse error -/// type, where lifetime `'i` will be eliminated. -#[derive(Clone, Debug)] -pub struct InvalidArguments<'i> { - /// Function name. - pub name: &'i str, - /// Error message. - pub message: String, - /// Span of the bad arguments. - pub span: pest::Span<'i>, -} - -/// Expression item that can be transformed recursively by using `folder: F`. -pub trait FoldableExpression<'i>: Sized { - /// Transforms `self` by applying the `folder` to inner items. - fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result - where - F: ExpressionFolder<'i, Self> + ?Sized; -} - -/// Visitor-like interface to transform AST nodes recursively. -pub trait ExpressionFolder<'i, T: FoldableExpression<'i>> { - /// Transform error. - type Error; - - /// Transforms the expression `node`. By default, inner items are - /// transformed recursively. - fn fold_expression( - &mut self, - node: ExpressionNode<'i, T>, - ) -> Result, Self::Error> { - let ExpressionNode { kind, span } = node; - let kind = kind.fold(self, span)?; - Ok(ExpressionNode { kind, span }) - } - - /// Transforms identifier. - fn fold_identifier(&mut self, name: &'i str, span: pest::Span<'i>) -> Result; - - /// Transforms pattern. - fn fold_pattern( - &mut self, - pattern: Box>, - span: pest::Span<'i>, - ) -> Result; - - /// Transforms function call. - fn fold_function_call( - &mut self, - function: Box>, - span: pest::Span<'i>, - ) -> Result; -} - -/// Transforms list of `nodes` by using `folder`. -pub fn fold_expression_nodes<'i, F, T>( - folder: &mut F, - nodes: Vec>, -) -> Result>, F::Error> -where - F: ExpressionFolder<'i, T> + ?Sized, - T: FoldableExpression<'i>, -{ - nodes - .into_iter() - .map(|node| folder.fold_expression(node)) - .try_collect() -} - -/// Transforms pattern value by using `folder`. -pub fn fold_pattern_value<'i, F, T>( - folder: &mut F, - pattern: PatternNode<'i, T>, -) -> Result, F::Error> -where - F: ExpressionFolder<'i, T> + ?Sized, - T: FoldableExpression<'i>, -{ - Ok(PatternNode { - name: pattern.name, - name_span: pattern.name_span, - value: folder.fold_expression(pattern.value)?, - }) -} - -/// Transforms function call arguments by using `folder`. -pub fn fold_function_call_args<'i, F, T>( - folder: &mut F, - function: FunctionCallNode<'i, T>, -) -> Result, F::Error> -where - F: ExpressionFolder<'i, T> + ?Sized, - T: FoldableExpression<'i>, -{ - Ok(FunctionCallNode { - name: function.name, - name_span: function.name_span, - args: fold_expression_nodes(folder, function.args)?, - keyword_args: function - .keyword_args - .into_iter() - .map(|arg| { - Ok(KeywordArgument { - name: arg.name, - name_span: arg.name_span, - value: folder.fold_expression(arg.value)?, - }) - }) - .try_collect()?, - args_span: function.args_span, - }) -} - -/// Helper to parse string literal. -#[derive(Debug)] -pub struct StringLiteralParser { - /// String content part. - pub content_rule: R, - /// Escape sequence part including backslash character. - pub escape_rule: R, -} - -impl StringLiteralParser { - /// Parses the given string literal `pairs` into string. - pub fn parse(&self, pairs: Pairs) -> String { - let mut result = String::new(); - for part in pairs { - if part.as_rule() == self.content_rule { - result.push_str(part.as_str()); - } else if part.as_rule() == self.escape_rule { - match &part.as_str()[1..] { - "\"" => result.push('"'), - "\\" => result.push('\\'), - "t" => result.push('\t'), - "r" => result.push('\r'), - "n" => result.push('\n'), - "0" => result.push('\0'), - "e" => result.push('\x1b'), - hex if hex.starts_with('x') => { - result.push(char::from( - u8::from_str_radix(&hex[1..], 16).expect("hex characters"), - )); - } - char => panic!("invalid escape: \\{char:?}"), - } - } else { - panic!("unexpected part of string: {part:?}"); - } - } - result - } -} - -/// Escape special characters in the input -pub fn escape_string(unescaped: &str) -> String { - let mut escaped = String::with_capacity(unescaped.len()); - for c in unescaped.chars() { - match c { - '"' => escaped.push_str(r#"\""#), - '\\' => escaped.push_str(r#"\\"#), - '\t' => escaped.push_str(r#"\t"#), - '\r' => escaped.push_str(r#"\r"#), - '\n' => escaped.push_str(r#"\n"#), - '\0' => escaped.push_str(r#"\0"#), - c if c.is_ascii_control() => { - for b in ascii::escape_default(c as u8) { - escaped.push(b as char); - } - } - c => escaped.push(c), - } - } - escaped -} - -/// Helper to parse function call. -#[derive(Debug)] -pub struct FunctionCallParser { - /// Function name. - pub function_name_rule: R, - /// List of positional and keyword arguments. - pub function_arguments_rule: R, - /// Pair of parameter name and value. - pub keyword_argument_rule: R, - /// Parameter name. - pub argument_name_rule: R, - /// Value expression. - pub argument_value_rule: R, -} - -impl FunctionCallParser { - /// Parses the given `pair` as function call. - pub fn parse<'i, T, E: From>>( - &self, - pair: Pair<'i, R>, - // parse_name can be defined for any Pair<'_, R>, but parse_value should - // be allowed to construct T by capturing Pair<'i, R>. - parse_name: impl Fn(Pair<'i, R>) -> Result<&'i str, E>, - parse_value: impl Fn(Pair<'i, R>) -> Result, E>, - ) -> Result, E> { - let [name_pair, args_pair] = pair.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), self.function_name_rule); - assert_eq!(args_pair.as_rule(), self.function_arguments_rule); - let name_span = name_pair.as_span(); - let args_span = args_pair.as_span(); - let function_name = parse_name(name_pair)?; - let mut args = Vec::new(); - let mut keyword_args = Vec::new(); - for pair in args_pair.into_inner() { - let span = pair.as_span(); - if pair.as_rule() == self.argument_value_rule { - if !keyword_args.is_empty() { - return Err(InvalidArguments { - name: function_name, - message: "Positional argument follows keyword argument".to_owned(), - span, - } - .into()); - } - args.push(parse_value(pair)?); - } else if pair.as_rule() == self.keyword_argument_rule { - let [name_pair, value_pair] = pair.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), self.argument_name_rule); - assert_eq!(value_pair.as_rule(), self.argument_value_rule); - let name_span = name_pair.as_span(); - let arg = KeywordArgument { - name: parse_name(name_pair)?, - name_span, - value: parse_value(value_pair)?, - }; - keyword_args.push(arg); - } else { - panic!("unexpected argument rule {pair:?}"); - } - } - Ok(FunctionCallNode { - name: function_name, - name_span, - args, - keyword_args, - args_span, - }) - } -} - -/// A function alias containing `(params, definition, description)`. -type FunctionAlias = (Vec, V, Option); - -/// Map of symbol, pattern, and function aliases. -#[derive(Clone, Debug, Default)] -pub struct AliasesMap { - symbol_aliases: HashMap)>, - // name: (param, defn) - pattern_aliases: HashMap)>, - // name: [(params, defn)] (sorted by arity) - function_aliases: HashMap>>, - // Parser type P helps prevent misuse of AliasesMap of different language. - parser: P, -} - -impl AliasesMap { - /// Creates an empty aliases map with default-constructed parser. - pub fn new() -> Self - where - P: Default, - { - Self { - symbol_aliases: Default::default(), - pattern_aliases: Default::default(), - function_aliases: Default::default(), - parser: Default::default(), - } - } - - /// Adds new substitution rule `decl = defn`. - /// - /// Returns error if `decl` is invalid. The `defn` part isn't checked. A bad - /// `defn` will be reported when the alias is substituted. - pub fn insert( - &mut self, - decl: impl AsRef, - defn: impl Into, - doc: Option, - ) -> Result<(), P::Error> - where - P: AliasDeclarationParser, - { - match self.parser.parse_declaration(decl.as_ref())? { - AliasDeclaration::Symbol(name) => { - self.symbol_aliases.insert(name, (defn.into(), doc)); - } - AliasDeclaration::Pattern(name, param) => { - self.pattern_aliases.insert(name, (param, defn.into(), doc)); - } - AliasDeclaration::Function(name, params) => { - let overloads = self.function_aliases.entry(name).or_default(); - match overloads.binary_search_by_key(¶ms.len(), |(params, _, _)| params.len()) { - Ok(i) => overloads[i] = (params, defn.into(), doc), - Err(i) => overloads.insert(i, (params, defn.into(), doc)), - } - } - } - Ok(()) - } - - /// Iterates symbol names in arbitrary order. - pub fn symbol_names(&self) -> impl Iterator { - self.symbol_aliases.keys().map(|n| n.as_ref()) - } - - /// Iterates pattern names in arbitrary order. - pub fn pattern_names(&self) -> impl Iterator { - self.pattern_aliases.keys().map(|n| n.as_ref()) - } - - /// Iterates function names in arbitrary order. - pub fn function_names(&self) -> impl Iterator { - self.function_aliases.keys().map(|n| n.as_ref()) - } - - /// Looks up symbol alias by name. Returns identifier, definition text, and - /// optional description. - pub fn get_symbol(&self, name: &str) -> Option<(AliasId<'_>, &V, Option<&str>)> { - self.symbol_aliases - .get_key_value(name) - .map(|(name, (defn, doc))| (AliasId::Symbol(name), defn, doc.as_deref())) - } - - /// Looks up pattern alias by name. Returns identifier, parameter name, - /// definition text, and optional description. - pub fn get_pattern(&self, name: &str) -> Option<(AliasId<'_>, &str, &V, Option<&str>)> { - self.pattern_aliases - .get_key_value(name) - .map(|(name, (param, defn, doc))| { - ( - AliasId::Pattern(name, param), - param.as_ref(), - defn, - doc.as_deref(), - ) - }) - } - - /// Looks up function alias by name and arity. Returns identifier, list of - /// parameter names, definition text, and optional description. - pub fn get_function( - &self, - name: &str, - arity: usize, - ) -> Option<(AliasId<'_>, &[String], &V, Option<&str>)> { - let overloads = self.get_function_overloads(name)?; - overloads.find_by_arity(arity) - } - - /// Looks up function aliases by name. - fn get_function_overloads(&self, name: &str) -> Option> { - let (name, overloads) = self.function_aliases.get_key_value(name)?; - Some(AliasFunctionOverloads { name, overloads }) - } -} - -#[derive(Clone, Debug)] -struct AliasFunctionOverloads<'a, V> { - name: &'a String, - overloads: &'a Vec<(Vec, V, Option)>, -} - -impl<'a, V> AliasFunctionOverloads<'a, V> { - fn arities(&self) -> impl DoubleEndedIterator + ExactSizeIterator { - self.overloads.iter().map(|(params, _, _)| params.len()) - } - - fn min_arity(&self) -> usize { - self.arities().next().unwrap() - } - - fn max_arity(&self) -> usize { - self.arities().next_back().unwrap() - } - - fn find_by_arity( - &self, - arity: usize, - ) -> Option<(AliasId<'a>, &'a [String], &'a V, Option<&'a str>)> { - let index = self - .overloads - .binary_search_by_key(&arity, |(params, _, _)| params.len()) - .ok()?; - let (params, defn, doc) = &self.overloads[index]; - // Exact parameter names aren't needed to identify a function, but they - // provide a better error indication. (e.g. "foo(x, y)" is easier to - // follow than "foo/2".) - Some(( - AliasId::Function(self.name, params), - params, - defn, - doc.as_deref(), - )) - } -} - -/// Borrowed reference to identify alias expression. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AliasId<'a> { - /// Symbol name. - Symbol(&'a str), - /// Pattern name and parameter name. - Pattern(&'a str, &'a str), - /// Function name and parameter names. - Function(&'a str, &'a [String]), - /// Function parameter name. - Parameter(&'a str), -} - -impl fmt::Display for AliasId<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Symbol(name) => write!(f, "{name}"), - Self::Pattern(name, param) => write!(f, "{name}:{param}"), - Self::Function(name, params) => { - write!(f, "{name}({params})", params = params.join(", ")) - } - Self::Parameter(name) => write!(f, "{name}"), - } - } -} - -/// Parsed declaration part of alias rule. -#[derive(Clone, Debug)] -pub enum AliasDeclaration { - /// Symbol name. - Symbol(String), - /// Pattern name and parameter. - Pattern(String, String), - /// Function name and parameters. - Function(String, Vec), -} - -// AliasDeclarationParser and AliasDefinitionParser can be merged into a single -// trait, but it's unclear whether doing that would simplify the abstraction. - -/// Parser for symbol and function alias declaration. -pub trait AliasDeclarationParser { - /// Parse error type. - type Error; - - /// Parses symbol or function name and parameters. - fn parse_declaration(&self, source: &str) -> Result; -} - -/// Parser for symbol and function alias definition. -pub trait AliasDefinitionParser { - /// Expression item type. - type Output<'i>; - /// Parse error type. - type Error; - - /// Parses alias body. - fn parse_definition<'i>( - &self, - source: &'i str, - ) -> Result>, Self::Error>; -} - -/// Expression item that supports alias substitution. -pub trait AliasExpandableExpression<'i>: FoldableExpression<'i> { - /// Wraps identifier. - fn identifier(name: &'i str) -> Self; - /// Wraps pattern. - fn pattern(pattern: Box>) -> Self; - /// Wraps function call. - fn function_call(function: Box>) -> Self; - /// Wraps substituted expression. - fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self; -} - -/// Error that may occur during alias substitution. -pub trait AliasExpandError: Sized { - /// Unexpected number of arguments, or invalid combination of arguments. - fn invalid_arguments(err: InvalidArguments<'_>) -> Self; - /// Recursion detected during alias substitution. - fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self; - /// Attaches alias trace to the current error. - fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self; -} - -/// Expands aliases recursively in tree of `T`. -#[derive(Debug)] -struct AliasExpander<'i, 'a, T, P> { - /// Alias symbols and functions that are globally available. - aliases_map: &'i AliasesMap, - /// Local variables set in the outermost scope. - locals: &'a HashMap<&'i str, ExpressionNode<'i, T>>, - /// Stack of aliases and local parameters currently expanding. - states: Vec>, -} - -#[derive(Debug)] -struct AliasExpandingState<'i, T> { - id: AliasId<'i>, - locals: HashMap<&'i str, ExpressionNode<'i, T>>, -} - -impl<'i, T, P, E> AliasExpander<'i, '_, T, P> -where - T: AliasExpandableExpression<'i> + Clone, - P: AliasDefinitionParser = T, Error = E>, - E: AliasExpandError, -{ - /// Local variables available to the current scope. - fn current_locals(&self) -> &HashMap<&'i str, ExpressionNode<'i, T>> { - self.states.last().map_or(self.locals, |s| &s.locals) - } - - fn expand_defn( - &mut self, - id: AliasId<'i>, - defn: &'i str, - locals: HashMap<&'i str, ExpressionNode<'i, T>>, - span: pest::Span<'i>, - ) -> Result { - // The stack should be short, so let's simply do linear search. - if self.states.iter().any(|s| s.id == id) { - return Err(E::recursive_expansion(id, span)); - } - self.states.push(AliasExpandingState { id, locals }); - // Parsed defn could be cached if needed. - let result = self - .aliases_map - .parser - .parse_definition(defn) - .and_then(|node| self.fold_expression(node)) - .map(|node| T::alias_expanded(id, Box::new(node))) - .map_err(|e| e.within_alias_expansion(id, span)); - self.states.pop(); - result - } -} - -impl<'i, T, P, E> ExpressionFolder<'i, T> for AliasExpander<'i, '_, T, P> -where - T: AliasExpandableExpression<'i> + Clone, - P: AliasDefinitionParser = T, Error = E>, - E: AliasExpandError, -{ - type Error = E; - - fn fold_identifier(&mut self, name: &'i str, span: pest::Span<'i>) -> Result { - if let Some(subst) = self.current_locals().get(name) { - let id = AliasId::Parameter(name); - Ok(T::alias_expanded(id, Box::new(subst.clone()))) - } else if let Some((id, defn, _doc)) = self.aliases_map.get_symbol(name) { - let locals = HashMap::new(); // Don't spill out the current scope - self.expand_defn(id, defn, locals, span) - } else { - Ok(T::identifier(name)) - } - } - - fn fold_pattern( - &mut self, - pattern: Box>, - span: pest::Span<'i>, - ) -> Result { - if let Some((id, param, defn, _doc)) = self.aliases_map.get_pattern(pattern.name) { - // Resolve argument in the current scope, and pass it in to the - // alias expansion scope. - let arg = self.fold_expression(pattern.value)?; - let locals = HashMap::from([(param, arg)]); - self.expand_defn(id, defn, locals, span) - } else { - let pattern = Box::new(fold_pattern_value(self, *pattern)?); - Ok(T::pattern(pattern)) - } - } - - fn fold_function_call( - &mut self, - function: Box>, - span: pest::Span<'i>, - ) -> Result { - // For better error indication, builtin functions are shadowed by name, - // not by (name, arity). - if let Some(overloads) = self.aliases_map.get_function_overloads(function.name) { - // TODO: add support for keyword arguments - function - .ensure_no_keyword_arguments() - .map_err(E::invalid_arguments)?; - let Some((id, params, defn, _doc)) = overloads.find_by_arity(function.arity()) else { - let min = overloads.min_arity(); - let max = overloads.max_arity(); - let err = if max - min + 1 == overloads.arities().len() { - function.invalid_arguments_count(min, Some(max)) - } else { - function.invalid_arguments_count_with_arities(overloads.arities()) - }; - return Err(E::invalid_arguments(err)); - }; - // Resolve arguments in the current scope, and pass them in to the alias - // expansion scope. - let args = fold_expression_nodes(self, function.args)?; - let locals = params.iter().map(|s| s.as_str()).zip(args).collect(); - self.expand_defn(id, defn, locals, span) - } else { - let function = Box::new(fold_function_call_args(self, *function)?); - Ok(T::function_call(function)) - } - } -} - -/// Expands aliases recursively. -pub fn expand_aliases<'i, T, P>( - node: ExpressionNode<'i, T>, - aliases_map: &'i AliasesMap, -) -> Result, P::Error> -where - T: AliasExpandableExpression<'i> + Clone, - P: AliasDefinitionParser = T>, - P::Error: AliasExpandError, -{ - expand_aliases_with_locals(node, aliases_map, &HashMap::new()) -} - -/// Expands aliases recursively with the outermost local variables. -/// -/// Local variables are similar to alias symbols, but are scoped. Alias symbols -/// are globally accessible from alias expressions, but local variables aren't. -pub fn expand_aliases_with_locals<'i, T, P>( - node: ExpressionNode<'i, T>, - aliases_map: &'i AliasesMap, - locals: &HashMap<&'i str, ExpressionNode<'i, T>>, -) -> Result, P::Error> -where - T: AliasExpandableExpression<'i> + Clone, - P: AliasDefinitionParser = T>, - P::Error: AliasExpandError, -{ - let mut expander = AliasExpander { - aliases_map, - locals, - states: Vec::new(), - }; - expander.fold_expression(node) -} - -/// Collects similar names from the `candidates` list. -pub fn collect_similar(name: &str, candidates: I) -> Vec -where - I: IntoIterator, - I::Item: AsRef, -{ - candidates - .into_iter() - .filter(|cand| { - // The parameter is borrowed from clap f5540d26 - strsim::jaro(name, cand.as_ref()) > 0.7 - }) - .map(|s| s.as_ref().to_owned()) - .sorted_unstable() - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_expect_arguments() { - fn empty_span() -> pest::Span<'static> { - pest::Span::new("", 0, 0).unwrap() - } - - fn function( - name: &'static str, - args: impl Into>>, - keyword_args: impl Into>>, - ) -> FunctionCallNode<'static, u32> { - FunctionCallNode { - name, - name_span: empty_span(), - args: args.into(), - keyword_args: keyword_args.into(), - args_span: empty_span(), - } - } - - fn value(v: u32) -> ExpressionNode<'static, u32> { - ExpressionNode::new(v, empty_span()) - } - - fn keyword(name: &'static str, v: u32) -> KeywordArgument<'static, u32> { - KeywordArgument { - name, - name_span: empty_span(), - value: value(v), - } - } - - let f = function("foo", [], []); - assert!(f.expect_no_arguments().is_ok()); - assert!(f.expect_some_arguments::<0>().is_ok()); - assert!(f.expect_arguments::<0, 0>().is_ok()); - assert!(f.expect_named_arguments::<0, 0>(&[]).is_ok()); - - let f = function("foo", [value(0)], []); - assert!(f.expect_no_arguments().is_err()); - assert_eq!( - f.expect_some_arguments::<0>().unwrap(), - (&[], [value(0)].as_slice()) - ); - assert_eq!( - f.expect_some_arguments::<1>().unwrap(), - (&[value(0)], [].as_slice()) - ); - assert!(f.expect_arguments::<0, 0>().is_err()); - assert_eq!( - f.expect_arguments::<0, 1>().unwrap(), - (&[], [Some(&value(0))]) - ); - assert_eq!(f.expect_arguments::<1, 1>().unwrap(), (&[value(0)], [None])); - assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); - assert_eq!( - f.expect_named_arguments::<0, 1>(&["a"]).unwrap(), - ([], [Some(&value(0))]) - ); - assert_eq!( - f.expect_named_arguments::<1, 0>(&["a"]).unwrap(), - ([&value(0)], []) - ); - - let f = function("foo", [], [keyword("a", 0)]); - assert!(f.expect_no_arguments().is_err()); - assert!(f.expect_some_arguments::<1>().is_err()); - assert!(f.expect_arguments::<0, 1>().is_err()); - assert!(f.expect_arguments::<1, 0>().is_err()); - assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); - assert!(f.expect_named_arguments::<0, 1>(&[]).is_err()); - assert!(f.expect_named_arguments::<1, 0>(&[]).is_err()); - assert_eq!( - f.expect_named_arguments::<1, 0>(&["a"]).unwrap(), - ([&value(0)], []) - ); - assert_eq!( - f.expect_named_arguments::<1, 1>(&["a", "b"]).unwrap(), - ([&value(0)], [None]) - ); - assert!(f.expect_named_arguments::<1, 1>(&["b", "a"]).is_err()); - - let f = function("foo", [value(0)], [keyword("a", 1), keyword("b", 2)]); - assert!(f.expect_named_arguments::<0, 0>(&[]).is_err()); - assert!(f.expect_named_arguments::<1, 1>(&["a", "b"]).is_err()); - assert_eq!( - f.expect_named_arguments::<1, 2>(&["c", "a", "b"]).unwrap(), - ([&value(0)], [Some(&value(1)), Some(&value(2))]) - ); - assert_eq!( - f.expect_named_arguments::<2, 1>(&["c", "b", "a"]).unwrap(), - ([&value(0), &value(2)], [Some(&value(1))]) - ); - assert_eq!( - f.expect_named_arguments::<0, 3>(&["c", "b", "a"]).unwrap(), - ([], [Some(&value(0)), Some(&value(2)), Some(&value(1))]) - ); - - let f = function("foo", [], [keyword("a", 0), keyword("a", 1)]); - assert!(f.expect_named_arguments::<1, 1>(&["", "a"]).is_err()); - } -} +pub use jj_core::dsl_util::AliasDeclaration; +pub use jj_core::dsl_util::AliasDeclarationParser; +pub use jj_core::dsl_util::AliasDefinitionParser; +pub use jj_core::dsl_util::AliasExpandError; +pub use jj_core::dsl_util::AliasExpandableExpression; +pub use jj_core::dsl_util::AliasId; +pub use jj_core::dsl_util::AliasesMap; +pub use jj_core::dsl_util::Diagnostics; +pub use jj_core::dsl_util::ExpressionFolder; +pub use jj_core::dsl_util::ExpressionNode; +pub use jj_core::dsl_util::FoldableExpression; +pub use jj_core::dsl_util::FunctionCallNode; +pub use jj_core::dsl_util::FunctionCallParser; +pub use jj_core::dsl_util::InvalidArguments; +pub use jj_core::dsl_util::KeywordArgument; +pub use jj_core::dsl_util::PatternNode; +pub use jj_core::dsl_util::StringLiteralParser; +pub use jj_core::dsl_util::collect_similar; +pub use jj_core::dsl_util::escape_string; +pub use jj_core::dsl_util::expand_aliases; +pub use jj_core::dsl_util::expand_aliases_with_locals; +pub use jj_core::dsl_util::fold_expression_nodes; +pub use jj_core::dsl_util::fold_function_call_args; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index e248c6e8f17..9b30fd61e22 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -84,7 +84,7 @@ pub mod op_walk; pub mod operation; #[expect(missing_docs)] pub mod protos; -pub mod ref_name; +pub use jj_core::ref_name; pub mod refs; pub mod repo; pub mod repo_path; diff --git a/lib/src/revset.rs b/lib/src/revset.rs index dbdd2cf9ad9..2b1f5c03dc8 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -29,6 +29,9 @@ use futures::StreamExt as _; use futures::future::LocalBoxFuture; use futures::stream::LocalBoxStream; use itertools::Itertools as _; +pub use jj_core::revset::format_remote_symbol; +pub use jj_core::revset::format_string; +pub use jj_core::revset::format_symbol; use pollster::FutureExt as _; use thiserror::Error; @@ -3578,31 +3581,6 @@ pub struct RevsetWorkspaceContext<'a> { pub workspace_name: &'a WorkspaceName, } -/// Formats a string as symbol by quoting and escaping it if necessary. -/// -/// Note that symbols may be substituted to user aliases. Use -/// [`format_string()`] to ensure that the provided string is resolved as a -/// tag/bookmark name, commit/change ID prefix, etc. -pub fn format_symbol(literal: &str) -> String { - if revset_parser::is_identifier(literal) { - literal.to_string() - } else { - format_string(literal) - } -} - -/// Formats a string by quoting and escaping it. -pub fn format_string(literal: &str) -> String { - format!(r#""{}""#, dsl_util::escape_string(literal)) -} - -/// Formats a `name@remote` symbol, applies quoting and escaping if necessary. -pub fn format_remote_symbol(name: &str, remote: &str) -> String { - let name = format_symbol(name); - let remote = format_symbol(remote); - format!("{name}@{remote}") -} - #[cfg(test)] #[rustversion::attr( since(1.89), diff --git a/lib/src/revset_parser.rs b/lib/src/revset_parser.rs index 163f6f2b585..ec0b29034c3 100644 --- a/lib/src/revset_parser.rs +++ b/lib/src/revset_parser.rs @@ -12,1948 +12,26 @@ // See the License for the specific language governing permissions and // limitations under the License. -#![expect(missing_docs)] - -use std::collections::HashSet; -use std::error; -use std::mem; -use std::str::FromStr; -use std::sync::LazyLock; - -use itertools::Itertools as _; -use pest::Parser as _; -use pest::iterators::Pair; -use pest::pratt_parser::Assoc; -use pest::pratt_parser::Op; -use pest::pratt_parser::PrattParser; -use pest_derive::Parser; -use thiserror::Error; - -use crate::dsl_util; -use crate::dsl_util::AliasDeclaration; -use crate::dsl_util::AliasDeclarationParser; -use crate::dsl_util::AliasDefinitionParser; -use crate::dsl_util::AliasExpandError; -use crate::dsl_util::AliasExpandableExpression; -use crate::dsl_util::AliasId; -use crate::dsl_util::AliasesMap; -use crate::dsl_util::Diagnostics; -use crate::dsl_util::ExpressionFolder; -use crate::dsl_util::FoldableExpression; -use crate::dsl_util::FunctionCallParser; -use crate::dsl_util::InvalidArguments; -use crate::dsl_util::StringLiteralParser; -use crate::dsl_util::collect_similar; -use crate::ref_name::RefNameBuf; -use crate::ref_name::RemoteNameBuf; -use crate::ref_name::RemoteRefSymbolBuf; - -#[derive(Parser)] -#[grammar = "revset.pest"] -struct RevsetParser; - -const STRING_LITERAL_PARSER: StringLiteralParser = StringLiteralParser { - content_rule: Rule::string_content, - escape_rule: Rule::string_escape, -}; -const FUNCTION_CALL_PARSER: FunctionCallParser = FunctionCallParser { - function_name_rule: Rule::function_name, - function_arguments_rule: Rule::function_arguments, - keyword_argument_rule: Rule::keyword_argument, - argument_name_rule: Rule::strict_identifier, - argument_value_rule: Rule::expression, -}; - -impl Rule { - /// Whether this is a placeholder rule for compatibility with the other - /// systems. - fn is_compat(&self) -> bool { - matches!( - self, - Self::compat_parents_op - | Self::compat_dag_range_op - | Self::compat_dag_range_pre_op - | Self::compat_dag_range_post_op - | Self::compat_add_op - | Self::compat_sub_op - ) - } - - fn to_symbol(self) -> Option<&'static str> { - match self { - Self::EOI => None, - Self::whitespace => None, - Self::identifier_part => None, - Self::identifier => None, - Self::strict_identifier_part => None, - Self::strict_identifier => None, - Self::symbol => None, - Self::string_escape => None, - Self::string_content_char => None, - Self::string_content => None, - Self::string_literal => None, - Self::raw_string_content => None, - Self::raw_string_literal => None, - Self::at_op => Some("@"), - Self::pattern_kind_op => Some(":"), - Self::parents_op => Some("-"), - Self::children_op => Some("+"), - Self::compat_parents_op => Some("^"), - Self::dag_range_op - | Self::dag_range_pre_op - | Self::dag_range_post_op - | Self::dag_range_all_op => Some("::"), - Self::compat_dag_range_op - | Self::compat_dag_range_pre_op - | Self::compat_dag_range_post_op => Some(":"), - Self::range_op => Some(".."), - Self::range_pre_op | Self::range_post_op | Self::range_all_op => Some(".."), - Self::range_ops => None, - Self::range_pre_ops => None, - Self::range_post_ops => None, - Self::range_all_ops => None, - Self::negate_op => Some("~"), - Self::union_op => Some("|"), - Self::intersection_op => Some("&"), - Self::difference_op => Some("~"), - Self::compat_add_op => Some("+"), - Self::compat_sub_op => Some("-"), - Self::infix_op => None, - Self::function => None, - Self::function_name => None, - Self::keyword_argument => None, - Self::argument => None, - Self::function_arguments => None, - Self::formal_parameters => None, - Self::pattern => None, - Self::pattern_value_expression => None, - Self::primary => None, - Self::neighbors_expression => None, - Self::range_expression => None, - Self::expression => None, - Self::program => None, - Self::symbol_name => None, - Self::function_alias_declaration => None, - Self::pattern_alias_declaration => None, - Self::alias_declaration => None, - } - } -} - -/// Manages diagnostic messages emitted during revset parsing and function-call -/// resolution. -pub type RevsetDiagnostics = Diagnostics; - -#[derive(Debug, Error)] -#[error("{pest_error}")] -pub struct RevsetParseError { - kind: Box, - pest_error: Box>, - source: Option>, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum RevsetParseErrorKind { - #[error("Syntax error")] - SyntaxError, - #[error("`{op}` is not a prefix operator")] - NotPrefixOperator { - op: String, - similar_op: String, - description: String, - }, - #[error("`{op}` is not a postfix operator")] - NotPostfixOperator { - op: String, - similar_op: String, - description: String, - }, - #[error("`{op}` is not an infix operator")] - NotInfixOperator { - op: String, - similar_op: String, - description: String, - }, - #[error("Function `{name}` doesn't exist")] - NoSuchFunction { - name: String, - candidates: Vec, - }, - #[error("Function `{name}`: {message}")] - InvalidFunctionArguments { name: String, message: String }, - #[error("Cannot resolve file pattern without workspace")] - FsPathWithoutWorkspace, - #[error("Cannot resolve `@` without workspace")] - WorkingCopyWithoutWorkspace, - #[error("Redefinition of function parameter")] - RedefinedFunctionParameter, - #[error("{0}")] - Expression(String), - #[error("In alias `{0}`")] - InAliasExpansion(String), - #[error("In function parameter `{0}`")] - InParameterExpansion(String), - #[error("Alias `{0}` expanded recursively")] - RecursiveAlias(String), -} - -impl RevsetParseError { - pub(super) fn with_span(kind: RevsetParseErrorKind, span: pest::Span<'_>) -> Self { - let message = kind.to_string(); - let pest_error = Box::new(pest::error::Error::new_from_span( - pest::error::ErrorVariant::CustomError { message }, - span, - )); - Self { - kind: Box::new(kind), - pest_error, - source: None, - } - } - - pub(super) fn with_source( - mut self, - source: impl Into>, - ) -> Self { - self.source = Some(source.into()); - self - } - - /// Some other expression error. - pub fn expression(message: impl Into, span: pest::Span<'_>) -> Self { - Self::with_span(RevsetParseErrorKind::Expression(message.into()), span) - } - - /// If this is a `NoSuchFunction` error, expands the candidates list with - /// the given `other_functions`. - pub(super) fn extend_function_candidates(mut self, other_functions: I) -> Self - where - I: IntoIterator, - I::Item: AsRef, - { - if let RevsetParseErrorKind::NoSuchFunction { name, candidates } = self.kind.as_mut() { - let other_candidates = collect_similar(name, other_functions); - *candidates = itertools::merge(mem::take(candidates), other_candidates) - .dedup() - .collect(); - } - self - } - - pub fn kind(&self) -> &RevsetParseErrorKind { - &self.kind - } - - /// Original parsing error which typically occurred in an alias expression. - pub fn origin(&self) -> Option<&Self> { - self.source.as_ref().and_then(|e| e.downcast_ref()) - } -} - -impl AliasExpandError for RevsetParseError { - fn invalid_arguments(err: InvalidArguments<'_>) -> Self { - err.into() - } - - fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self { - Self::with_span(RevsetParseErrorKind::RecursiveAlias(id.to_string()), span) - } - - fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self { - let kind = match id { - AliasId::Symbol(_) | AliasId::Pattern(..) | AliasId::Function(..) => { - RevsetParseErrorKind::InAliasExpansion(id.to_string()) - } - AliasId::Parameter(_) => RevsetParseErrorKind::InParameterExpansion(id.to_string()), - }; - Self::with_span(kind, span).with_source(self) - } -} - -impl From> for RevsetParseError { - fn from(err: pest::error::Error) -> Self { - Self { - kind: Box::new(RevsetParseErrorKind::SyntaxError), - pest_error: Box::new(rename_rules_in_pest_error(err)), - source: None, - } - } -} - -impl From> for RevsetParseError { - fn from(err: InvalidArguments<'_>) -> Self { - let kind = RevsetParseErrorKind::InvalidFunctionArguments { - name: err.name.to_owned(), - message: err.message, - }; - Self::with_span(kind, err.span) - } -} - -fn rename_rules_in_pest_error(mut err: pest::error::Error) -> pest::error::Error { - let pest::error::ErrorVariant::ParsingError { - positives, - negatives, - } = &mut err.variant - else { - return err; - }; - - // Remove duplicated symbols. Compat symbols are also removed from the - // (positive) suggestion. - let mut known_syms = HashSet::new(); - positives.retain(|rule| { - !rule.is_compat() && rule.to_symbol().is_none_or(|sym| known_syms.insert(sym)) - }); - let mut known_syms = HashSet::new(); - negatives.retain(|rule| rule.to_symbol().is_none_or(|sym| known_syms.insert(sym))); - err.renamed_rules(|rule| { - rule.to_symbol() - .map(|sym| format!("`{sym}`")) - .unwrap_or_else(|| format!("<{rule:?}>")) - }) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ExpressionKind<'i> { - /// Unquoted symbol. - Identifier(&'i str), - /// Quoted symbol or string. - String(String), - /// `:` where `` is usually `Identifier` or `String`. - Pattern(Box>), - /// `@` - RemoteSymbol(RemoteRefSymbolBuf), - /// `@` - AtWorkspace(String), - /// `@` - AtCurrentWorkspace, - /// `::` - DagRangeAll, - /// `..` - RangeAll, - Unary(UnaryOp, Box>), - Binary(BinaryOp, Box>, Box>), - /// `x | y | ..` - UnionAll(Vec>), - FunctionCall(Box>), - /// Identity node to preserve the span in the source text. - AliasExpanded(AliasId<'i>, Box>), -} - -impl<'i> FoldableExpression<'i> for ExpressionKind<'i> { - fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result - where - F: ExpressionFolder<'i, Self> + ?Sized, - { - match self { - Self::Identifier(name) => folder.fold_identifier(name, span), - Self::String(_) => Ok(self), - Self::Pattern(pattern) => folder.fold_pattern(pattern, span), - Self::RemoteSymbol(_) - | ExpressionKind::AtWorkspace(_) - | Self::AtCurrentWorkspace - | Self::DagRangeAll - | Self::RangeAll => Ok(self), - Self::Unary(op, arg) => { - let arg = Box::new(folder.fold_expression(*arg)?); - Ok(Self::Unary(op, arg)) - } - Self::Binary(op, lhs, rhs) => { - let lhs = Box::new(folder.fold_expression(*lhs)?); - let rhs = Box::new(folder.fold_expression(*rhs)?); - Ok(Self::Binary(op, lhs, rhs)) - } - Self::UnionAll(nodes) => { - let nodes = dsl_util::fold_expression_nodes(folder, nodes)?; - Ok(Self::UnionAll(nodes)) - } - Self::FunctionCall(function) => folder.fold_function_call(function, span), - Self::AliasExpanded(id, subst) => { - let subst = Box::new(folder.fold_expression(*subst)?); - Ok(Self::AliasExpanded(id, subst)) - } - } - } -} - -impl<'i> AliasExpandableExpression<'i> for ExpressionKind<'i> { - fn identifier(name: &'i str) -> Self { - Self::Identifier(name) - } - - fn pattern(pattern: Box>) -> Self { - Self::Pattern(pattern) - } - - fn function_call(function: Box>) -> Self { - Self::FunctionCall(function) - } - - fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self { - Self::AliasExpanded(id, subst) - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum UnaryOp { - /// `~x` - Negate, - /// `::x` - DagRangePre, - /// `x::` - DagRangePost, - /// `..x` - RangePre, - /// `x..` - RangePost, - /// `x-` - Parents, - /// `x+` - Children, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum BinaryOp { - /// `&` - Intersection, - /// `~` - Difference, - /// `::` - DagRange, - /// `..` - Range, -} - -pub type ExpressionNode<'i> = dsl_util::ExpressionNode<'i, ExpressionKind<'i>>; -pub type FunctionCallNode<'i> = dsl_util::FunctionCallNode<'i, ExpressionKind<'i>>; -pub type PatternNode<'i> = dsl_util::PatternNode<'i, ExpressionKind<'i>>; - -fn union_nodes<'i>(lhs: ExpressionNode<'i>, rhs: ExpressionNode<'i>) -> ExpressionNode<'i> { - let span = lhs.span.start_pos().span(&rhs.span.end_pos()); - let expr = match lhs.kind { - // Flatten "x | y | z" to save recursion stack. Machine-generated query - // might have long chain of unions. - ExpressionKind::UnionAll(mut nodes) => { - nodes.push(rhs); - ExpressionKind::UnionAll(nodes) - } - _ => ExpressionKind::UnionAll(vec![lhs, rhs]), - }; - ExpressionNode::new(expr, span) -} - -/// Parses text into expression tree. No name resolution is made at this stage. -pub fn parse_program(revset_str: &str) -> Result, RevsetParseError> { - let mut pairs = RevsetParser::parse(Rule::program, revset_str)?; - let first = pairs.next().unwrap(); - assert_eq!(first.as_rule(), Rule::expression); - parse_expression_node(first) -} - -fn parse_expression_node(pair: Pair) -> Result { - fn not_prefix_op( - op: &Pair, - similar_op: impl Into, - description: impl Into, - ) -> RevsetParseError { - RevsetParseError::with_span( - RevsetParseErrorKind::NotPrefixOperator { - op: op.as_str().to_owned(), - similar_op: similar_op.into(), - description: description.into(), - }, - op.as_span(), - ) - } - - fn not_postfix_op( - op: &Pair, - similar_op: impl Into, - description: impl Into, - ) -> RevsetParseError { - RevsetParseError::with_span( - RevsetParseErrorKind::NotPostfixOperator { - op: op.as_str().to_owned(), - similar_op: similar_op.into(), - description: description.into(), - }, - op.as_span(), - ) - } - - fn not_infix_op( - op: &Pair, - similar_op: impl Into, - description: impl Into, - ) -> RevsetParseError { - RevsetParseError::with_span( - RevsetParseErrorKind::NotInfixOperator { - op: op.as_str().to_owned(), - similar_op: similar_op.into(), - description: description.into(), - }, - op.as_span(), - ) - } - - static PRATT: LazyLock> = LazyLock::new(|| { - PrattParser::new() - .op(Op::infix(Rule::union_op, Assoc::Left) - | Op::infix(Rule::compat_add_op, Assoc::Left)) - .op(Op::infix(Rule::intersection_op, Assoc::Left) - | Op::infix(Rule::difference_op, Assoc::Left) - | Op::infix(Rule::compat_sub_op, Assoc::Left)) - .op(Op::prefix(Rule::negate_op)) - // Ranges can't be nested without parentheses. Associativity doesn't matter. - .op(Op::infix(Rule::dag_range_op, Assoc::Left) - | Op::infix(Rule::compat_dag_range_op, Assoc::Left) - | Op::infix(Rule::range_op, Assoc::Left)) - .op(Op::prefix(Rule::dag_range_pre_op) - | Op::prefix(Rule::compat_dag_range_pre_op) - | Op::prefix(Rule::range_pre_op)) - .op(Op::postfix(Rule::dag_range_post_op) - | Op::postfix(Rule::compat_dag_range_post_op) - | Op::postfix(Rule::range_post_op)) - // Neighbors - .op(Op::postfix(Rule::parents_op) - | Op::postfix(Rule::children_op) - | Op::postfix(Rule::compat_parents_op)) - }); - PRATT - .map_primary(|primary| { - let expr = match primary.as_rule() { - Rule::primary => return parse_primary_node(primary), - Rule::dag_range_all_op => ExpressionKind::DagRangeAll, - Rule::range_all_op => ExpressionKind::RangeAll, - r => panic!("unexpected primary rule {r:?}"), - }; - Ok(ExpressionNode::new(expr, primary.as_span())) - }) - .map_prefix(|op, rhs| { - let op_kind = match op.as_rule() { - Rule::negate_op => UnaryOp::Negate, - Rule::dag_range_pre_op => UnaryOp::DagRangePre, - Rule::compat_dag_range_pre_op => Err(not_prefix_op(&op, "::", "ancestors"))?, - Rule::range_pre_op => UnaryOp::RangePre, - r => panic!("unexpected prefix operator rule {r:?}"), - }; - let rhs = Box::new(rhs?); - let span = op.as_span().start_pos().span(&rhs.span.end_pos()); - let expr = ExpressionKind::Unary(op_kind, rhs); - Ok(ExpressionNode::new(expr, span)) - }) - .map_postfix(|lhs, op| { - let op_kind = match op.as_rule() { - Rule::dag_range_post_op => UnaryOp::DagRangePost, - Rule::compat_dag_range_post_op => Err(not_postfix_op(&op, "::", "descendants"))?, - Rule::range_post_op => UnaryOp::RangePost, - Rule::parents_op => UnaryOp::Parents, - Rule::children_op => UnaryOp::Children, - Rule::compat_parents_op => Err(not_postfix_op(&op, "-", "parents"))?, - r => panic!("unexpected postfix operator rule {r:?}"), - }; - let lhs = Box::new(lhs?); - let span = lhs.span.start_pos().span(&op.as_span().end_pos()); - let expr = ExpressionKind::Unary(op_kind, lhs); - Ok(ExpressionNode::new(expr, span)) - }) - .map_infix(|lhs, op, rhs| { - let op_kind = match op.as_rule() { - Rule::union_op => return Ok(union_nodes(lhs?, rhs?)), - Rule::compat_add_op => Err(not_infix_op(&op, "|", "union"))?, - Rule::intersection_op => BinaryOp::Intersection, - Rule::difference_op => BinaryOp::Difference, - Rule::compat_sub_op => Err(not_infix_op(&op, "~", "difference"))?, - Rule::dag_range_op => BinaryOp::DagRange, - Rule::compat_dag_range_op => Err(not_infix_op(&op, "::", "DAG range"))?, - Rule::range_op => BinaryOp::Range, - r => panic!("unexpected infix operator rule {r:?}"), - }; - let lhs = Box::new(lhs?); - let rhs = Box::new(rhs?); - let span = lhs.span.start_pos().span(&rhs.span.end_pos()); - let expr = ExpressionKind::Binary(op_kind, lhs, rhs); - Ok(ExpressionNode::new(expr, span)) - }) - .parse(pair.into_inner()) -} - -fn parse_primary_node(pair: Pair) -> Result { - let span = pair.as_span(); - let mut pairs = pair.into_inner(); - let first = pairs.next().unwrap(); - let expr = match first.as_rule() { - // Ignore inner span to preserve parenthesized expression as such. - Rule::expression => parse_expression_node(first)?.kind, - Rule::function => { - let function = Box::new(FUNCTION_CALL_PARSER.parse( - first, - |pair| Ok(pair.as_str()), - |pair| parse_expression_node(pair), - )?); - ExpressionKind::FunctionCall(function) - } - Rule::pattern => { - let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); - assert_eq!(lhs.as_rule(), Rule::strict_identifier); - assert_eq!(op.as_rule(), Rule::pattern_kind_op); - assert_eq!(rhs.as_rule(), Rule::pattern_value_expression); - let pattern = Box::new(PatternNode { - name: lhs.as_str(), - name_span: lhs.as_span(), - value: parse_expression_node(rhs)?, - }); - ExpressionKind::Pattern(pattern) - } - // Identifier without "@" may be substituted by aliases. Primary expression including "@" - // is considered an indecomposable unit, and no alias substitution would be made. - Rule::identifier if pairs.peek().is_none() => ExpressionKind::Identifier(first.as_str()), - Rule::identifier | Rule::string_literal | Rule::raw_string_literal => { - let name = parse_as_string_literal(first); - match pairs.next() { - None => ExpressionKind::String(name), - Some(op) => { - assert_eq!(op.as_rule(), Rule::at_op); - match pairs.next() { - // postfix "@" - None => ExpressionKind::AtWorkspace(name), - // infix "@" - Some(second) => { - let name: RefNameBuf = name.into(); - let remote: RemoteNameBuf = parse_as_string_literal(second).into(); - ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { name, remote }) - } - } - } - } - } - // nullary "@" - Rule::at_op => ExpressionKind::AtCurrentWorkspace, - r => panic!("unexpected revset parse rule: {r:?}"), - }; - Ok(ExpressionNode::new(expr, span)) -} - -/// Parses part of compound symbol to string. -fn parse_as_string_literal(pair: Pair) -> String { - match pair.as_rule() { - Rule::identifier => pair.as_str().to_owned(), - Rule::string_literal => STRING_LITERAL_PARSER.parse(pair.into_inner()), - Rule::raw_string_literal => { - let [content] = pair.into_inner().collect_array().unwrap(); - assert_eq!(content.as_rule(), Rule::raw_string_content); - content.as_str().to_owned() - } - _ => { - panic!("unexpected string literal rule: {:?}", pair.as_str()); - } - } -} - -/// Checks if the text is a valid identifier -pub fn is_identifier(text: &str) -> bool { - match RevsetParser::parse(Rule::identifier, text) { - Ok(mut pairs) => pairs.next().unwrap().as_span().end() == text.len(), - Err(_) => false, - } -} - -/// Parses the text as a revset symbol, rejects empty string. -pub fn parse_symbol(text: &str) -> Result { - let mut pairs = RevsetParser::parse(Rule::symbol_name, text)?; - let first = pairs.next().unwrap(); - let span = first.as_span(); - let name = parse_as_string_literal(first); - if name.is_empty() { - Err(RevsetParseError::expression( - "Expected non-empty string", - span, - )) - } else { - Ok(name) - } -} - -pub type RevsetAliasesMap = AliasesMap; - -#[derive(Clone, Debug, Default)] -pub struct RevsetAliasParser; - -impl AliasDeclarationParser for RevsetAliasParser { - type Error = RevsetParseError; - - fn parse_declaration(&self, source: &str) -> Result { - let mut pairs = RevsetParser::parse(Rule::alias_declaration, source)?; - let first = pairs.next().unwrap(); - match first.as_rule() { - Rule::strict_identifier => Ok(AliasDeclaration::Symbol(first.as_str().to_owned())), - Rule::pattern_alias_declaration => { - let [name_pair, op, param_pair] = first.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), Rule::strict_identifier); - assert_eq!(op.as_rule(), Rule::pattern_kind_op); - assert_eq!(param_pair.as_rule(), Rule::strict_identifier); - let name = name_pair.as_str().to_owned(); - let param = param_pair.as_str().to_owned(); - Ok(AliasDeclaration::Pattern(name, param)) - } - Rule::function_alias_declaration => { - let [name_pair, params_pair] = first.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), Rule::function_name); - assert_eq!(params_pair.as_rule(), Rule::formal_parameters); - let name = name_pair.as_str().to_owned(); - let params_span = params_pair.as_span(); - let params = params_pair - .into_inner() - .map(|pair| match pair.as_rule() { - Rule::strict_identifier => pair.as_str().to_owned(), - r => panic!("unexpected formal parameter rule {r:?}"), - }) - .collect_vec(); - if params.iter().all_unique() { - Ok(AliasDeclaration::Function(name, params)) - } else { - Err(RevsetParseError::with_span( - RevsetParseErrorKind::RedefinedFunctionParameter, - params_span, - )) - } - } - r => panic!("unexpected alias declaration rule {r:?}"), - } - } -} - -impl AliasDefinitionParser for RevsetAliasParser { - type Output<'i> = ExpressionKind<'i>; - type Error = RevsetParseError; - - fn parse_definition<'i>(&self, source: &'i str) -> Result, Self::Error> { - parse_program(source) - } -} - -pub(super) fn expect_string_pattern<'a>( - type_name: &str, - node: &'a ExpressionNode<'_>, -) -> Result<(&'a str, Option<&'a str>), RevsetParseError> { - catch_aliases_no_diagnostics(node, |node| match &node.kind { - ExpressionKind::Identifier(name) => Ok((*name, None)), - ExpressionKind::String(name) => Ok((name, None)), - ExpressionKind::Pattern(pattern) => { - let value = expect_string_literal("string", &pattern.value)?; - Ok((value, Some(pattern.name))) - } - _ => Err(RevsetParseError::expression( - format!("Expected {type_name}"), - node.span, - )), - }) -} - -pub fn expect_literal( - type_name: &str, - node: &ExpressionNode, -) -> Result { - catch_aliases_no_diagnostics(node, |node| { - let value = expect_string_literal(type_name, node)?; - value - .parse() - .map_err(|_| RevsetParseError::expression(format!("Expected {type_name}"), node.span)) - }) -} - -pub(super) fn expect_string_literal<'a>( - type_name: &str, - node: &'a ExpressionNode<'_>, -) -> Result<&'a str, RevsetParseError> { - catch_aliases_no_diagnostics(node, |node| match &node.kind { - ExpressionKind::Identifier(name) => Ok(*name), - ExpressionKind::String(name) => Ok(name), - _ => Err(RevsetParseError::expression( - format!("Expected {type_name}"), - node.span, - )), - }) -} - -/// Applies the given function to the innermost `node` by unwrapping alias -/// expansion nodes. Appends alias expansion stack to error and diagnostics. -pub(super) fn catch_aliases<'a, 'i, T>( - diagnostics: &mut RevsetDiagnostics, - node: &'a ExpressionNode<'i>, - f: impl FnOnce(&mut RevsetDiagnostics, &'a ExpressionNode<'i>) -> Result, -) -> Result { - let (node, stack) = skip_aliases(node); - if stack.is_empty() { - f(diagnostics, node) - } else { - let mut inner_diagnostics = RevsetDiagnostics::new(); - let result = f(&mut inner_diagnostics, node); - diagnostics.extend_with(inner_diagnostics, |diag| attach_aliases_err(diag, &stack)); - result.map_err(|err| attach_aliases_err(err, &stack)) - } -} - -fn catch_aliases_no_diagnostics<'a, 'i, T>( - node: &'a ExpressionNode<'i>, - f: impl FnOnce(&'a ExpressionNode<'i>) -> Result, -) -> Result { - let (node, stack) = skip_aliases(node); - f(node).map_err(|err| attach_aliases_err(err, &stack)) -} - -fn skip_aliases<'a, 'i>( - mut node: &'a ExpressionNode<'i>, -) -> (&'a ExpressionNode<'i>, Vec<(AliasId<'i>, pest::Span<'i>)>) { - let mut stack = Vec::new(); - while let ExpressionKind::AliasExpanded(id, subst) = &node.kind { - stack.push((*id, node.span)); - node = subst; - } - (node, stack) -} - -fn attach_aliases_err( - err: RevsetParseError, - stack: &[(AliasId<'_>, pest::Span<'_>)], -) -> RevsetParseError { - stack - .iter() - .rfold(err, |err, &(id, span)| err.within_alias_expansion(id, span)) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use assert_matches::assert_matches; - - use super::*; - use crate::dsl_util::KeywordArgument; - use crate::tests::TestResult; - - #[derive(Debug)] - struct WithRevsetAliasesMap<'i> { - aliases_map: RevsetAliasesMap, - locals: HashMap<&'i str, ExpressionNode<'i>>, - } - - impl<'i> WithRevsetAliasesMap<'i> { - fn set_local(mut self, name: &'i str, value: &'i str) -> Self { - self.locals.insert(name, parse_program(value).unwrap()); - self - } - - fn parse(&'i self, text: &'i str) -> Result, RevsetParseError> { - let node = parse_program(text)?; - dsl_util::expand_aliases_with_locals(node, &self.aliases_map, &self.locals) - } - - fn parse_normalized(&'i self, text: &'i str) -> ExpressionNode<'i> { - normalize_tree(self.parse(text).unwrap()) - } - } - - fn with_aliases<'i>( - aliases: impl IntoIterator, impl Into)>, - ) -> WithRevsetAliasesMap<'i> { - let mut aliases_map = RevsetAliasesMap::new(); - for (decl, defn) in aliases { - aliases_map.insert(decl, defn, None).unwrap(); - } - WithRevsetAliasesMap { - aliases_map, - locals: HashMap::new(), - } - } - - fn parse_into_kind(text: &str) -> Result, RevsetParseErrorKind> { - parse_program(text) - .map(|node| node.kind) - .map_err(|err| *err.kind) - } - - fn parse_normalized(text: &str) -> ExpressionNode<'_> { - normalize_tree(parse_program(text).unwrap()) - } - - /// Drops auxiliary data from parsed tree so it can be compared with other. - fn normalize_tree(node: ExpressionNode) -> ExpressionNode { - fn empty_span() -> pest::Span<'static> { - pest::Span::new("", 0, 0).unwrap() - } - - fn normalize_list(nodes: Vec) -> Vec { - nodes.into_iter().map(normalize_tree).collect() - } - - fn normalize_function_call(function: FunctionCallNode) -> FunctionCallNode { - FunctionCallNode { - name: function.name, - name_span: empty_span(), - args: normalize_list(function.args), - keyword_args: function - .keyword_args - .into_iter() - .map(|arg| KeywordArgument { - name: arg.name, - name_span: empty_span(), - value: normalize_tree(arg.value), - }) - .collect(), - args_span: empty_span(), - } - } - - let normalized_kind = match node.kind { - ExpressionKind::Identifier(_) | ExpressionKind::String(_) => node.kind, - ExpressionKind::Pattern(pattern) => { - let pattern = Box::new(PatternNode { - name: pattern.name, - name_span: empty_span(), - value: normalize_tree(pattern.value), - }); - ExpressionKind::Pattern(pattern) - } - ExpressionKind::RemoteSymbol(_) - | ExpressionKind::AtWorkspace(_) - | ExpressionKind::AtCurrentWorkspace - | ExpressionKind::DagRangeAll - | ExpressionKind::RangeAll => node.kind, - ExpressionKind::Unary(op, arg) => { - let arg = Box::new(normalize_tree(*arg)); - ExpressionKind::Unary(op, arg) - } - ExpressionKind::Binary(op, lhs, rhs) => { - let lhs = Box::new(normalize_tree(*lhs)); - let rhs = Box::new(normalize_tree(*rhs)); - ExpressionKind::Binary(op, lhs, rhs) - } - ExpressionKind::UnionAll(nodes) => { - let nodes = normalize_list(nodes); - ExpressionKind::UnionAll(nodes) - } - ExpressionKind::FunctionCall(function) => { - let function = Box::new(normalize_function_call(*function)); - ExpressionKind::FunctionCall(function) - } - ExpressionKind::AliasExpanded(_, subst) => normalize_tree(*subst).kind, - }; - ExpressionNode { - kind: normalized_kind, - span: empty_span(), - } - } - - #[test] - fn test_parse_tree_eq() { - assert_eq!( - parse_normalized(r#" foo( x ) | ~bar:"baz" "#), - parse_normalized(r#"(foo(x))|(~(bar:"baz"))"#) - ); - assert_ne!(parse_normalized(r#" foo "#), parse_normalized(r#" "foo" "#)); - } - - #[test] - fn test_parse_revset() -> TestResult { - // Parse a quoted symbol - assert_eq!( - parse_into_kind("\"foo\""), - Ok(ExpressionKind::String("foo".to_owned())) - ); - assert_eq!( - parse_into_kind("'foo'"), - Ok(ExpressionKind::String("foo".to_owned())) - ); - // Parse the "parents" operator - assert_matches!( - parse_into_kind("foo-"), - Ok(ExpressionKind::Unary(UnaryOp::Parents, _)) - ); - // Parse the "children" operator - assert_matches!( - parse_into_kind("foo+"), - Ok(ExpressionKind::Unary(UnaryOp::Children, _)) - ); - // Parse the "ancestors" operator - assert_matches!( - parse_into_kind("::foo"), - Ok(ExpressionKind::Unary(UnaryOp::DagRangePre, _)) - ); - // Parse the "descendants" operator - assert_matches!( - parse_into_kind("foo::"), - Ok(ExpressionKind::Unary(UnaryOp::DagRangePost, _)) - ); - // Parse the "dag range" operator - assert_matches!( - parse_into_kind("foo::bar"), - Ok(ExpressionKind::Binary(BinaryOp::DagRange, _, _)) - ); - // Parse the nullary "dag range" operator - assert_matches!(parse_into_kind("::"), Ok(ExpressionKind::DagRangeAll)); - // Parse the "range" prefix operator - assert_matches!( - parse_into_kind("..foo"), - Ok(ExpressionKind::Unary(UnaryOp::RangePre, _)) - ); - assert_matches!( - parse_into_kind("foo.."), - Ok(ExpressionKind::Unary(UnaryOp::RangePost, _)) - ); - assert_matches!( - parse_into_kind("foo..bar"), - Ok(ExpressionKind::Binary(BinaryOp::Range, _, _)) - ); - // Parse the nullary "range" operator - assert_matches!(parse_into_kind(".."), Ok(ExpressionKind::RangeAll)); - // Parse the "negate" operator - assert_matches!( - parse_into_kind("~ foo"), - Ok(ExpressionKind::Unary(UnaryOp::Negate, _)) - ); - assert_eq!( - parse_normalized("~ ~~ foo"), - parse_normalized("~(~(~(foo)))"), - ); - // Parse the "intersection" operator - assert_matches!( - parse_into_kind("foo & bar"), - Ok(ExpressionKind::Binary(BinaryOp::Intersection, _, _)) - ); - // Parse the "union" operator - assert_matches!( - parse_into_kind("foo | bar"), - Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 2 - ); - assert_matches!( - parse_into_kind("foo | bar | baz"), - Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 3 - ); - // Parse the "difference" operator - assert_matches!( - parse_into_kind("foo ~ bar"), - Ok(ExpressionKind::Binary(BinaryOp::Difference, _, _)) - ); - // Parentheses are allowed before suffix operators - assert_eq!(parse_normalized("(foo)-"), parse_normalized("foo-")); - // Space is allowed around expressions - assert_eq!(parse_normalized(" ::foo "), parse_normalized("::foo")); - assert_eq!(parse_normalized("( ::foo )"), parse_normalized("::foo")); - // Space is not allowed around prefix operators - assert_eq!( - parse_into_kind(" :: foo "), - Err(RevsetParseErrorKind::SyntaxError) - ); - // Incomplete parse - assert_eq!( - parse_into_kind("foo | -"), - Err(RevsetParseErrorKind::SyntaxError) - ); - - // Expression span - assert_eq!(parse_program(" ~ x ")?.span.as_str(), "~ x"); - assert_eq!(parse_program(" x+ ")?.span.as_str(), "x+"); - assert_eq!(parse_program(" x |y ")?.span.as_str(), "x |y"); - assert_eq!(parse_program(" (x) ")?.span.as_str(), "(x)"); - assert_eq!(parse_program("~( x|y) ")?.span.as_str(), "~( x|y)"); - assert_eq!(parse_program(" ( x )- ")?.span.as_str(), "( x )-"); - Ok(()) - } - - #[test] - fn test_parse_whitespace() { - let ascii_whitespaces: String = ('\x00'..='\x7f') - .filter(char::is_ascii_whitespace) - .collect(); - assert_eq!( - parse_normalized(&format!("{ascii_whitespaces}all()")), - parse_normalized("all()"), - ); - } - - #[test] - fn test_parse_identifier() { - // Integer is a symbol - assert_eq!(parse_into_kind("0"), Ok(ExpressionKind::Identifier("0"))); - // Tag/bookmark name separated by / - assert_eq!( - parse_into_kind("foo_bar/baz"), - Ok(ExpressionKind::Identifier("foo_bar/baz")) - ); - // Glob literal with star - assert_eq!( - parse_into_kind("*/foo/**"), - Ok(ExpressionKind::Identifier("*/foo/**")) - ); - - // Internal '.', '-', and '+' are allowed - assert_eq!( - parse_into_kind("foo.bar-v1+7"), - Ok(ExpressionKind::Identifier("foo.bar-v1+7")) - ); - assert_eq!( - parse_normalized("foo.bar-v1+7-"), - parse_normalized("(foo.bar-v1+7)-") - ); - // Multiple '-' are allowed - assert_eq!( - parse_into_kind("foo--bar"), - Ok(ExpressionKind::Identifier("foo--bar")) - ); - assert_eq!( - parse_into_kind("foo----bar"), - Ok(ExpressionKind::Identifier("foo----bar")) - ); - // '.' is not allowed at the beginning or end - assert_eq!( - parse_into_kind(".foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo."), - Err(RevsetParseErrorKind::SyntaxError) - ); - // Multiple '.' and '+', or together with '-', are not allowed - assert_eq!( - parse_into_kind("foo.+bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo++bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo+-bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - - // Parse a parenthesized symbol - assert_eq!(parse_normalized("(foo)"), parse_normalized("foo")); - - // Non-ASCII tag/bookmark name - assert_eq!( - parse_into_kind("柔術+jj"), - Ok(ExpressionKind::Identifier("柔術+jj")) - ); - } - - #[test] - fn test_parse_string_literal() { - // "\" escapes - assert_eq!( - parse_into_kind(r#" "\t\r\n\"\\\0\e" "#), - Ok(ExpressionKind::String("\t\r\n\"\\\0\u{1b}".to_owned())) - ); - - // Invalid "\" escape - assert_eq!( - parse_into_kind(r#" "\y" "#), - Err(RevsetParseErrorKind::SyntaxError) - ); - - // Single-quoted raw string - assert_eq!( - parse_into_kind(r#" '' "#), - Ok(ExpressionKind::String("".to_owned())) - ); - assert_eq!( - parse_into_kind(r#" 'a\n' "#), - Ok(ExpressionKind::String(r"a\n".to_owned())) - ); - assert_eq!( - parse_into_kind(r#" '\' "#), - Ok(ExpressionKind::String(r"\".to_owned())) - ); - assert_eq!( - parse_into_kind(r#" '"' "#), - Ok(ExpressionKind::String(r#"""#.to_owned())) - ); - - // Hex bytes - assert_eq!( - parse_into_kind(r#""\x61\x65\x69\x6f\x75""#), - Ok(ExpressionKind::String("aeiou".to_owned())) - ); - assert_eq!( - parse_into_kind(r#""\xe0\xe8\xec\xf0\xf9""#), - Ok(ExpressionKind::String("àèìðù".to_owned())) - ); - assert_eq!( - parse_into_kind(r#""\x""#), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind(r#""\xf""#), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind(r#""\xgg""#), - Err(RevsetParseErrorKind::SyntaxError) - ); - } - - #[test] - fn test_parse_pattern() -> TestResult { - fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { - match kind { - ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), - _ => panic!("unexpected expression: {kind:?}"), - } - } - - assert_eq!( - unwrap_pattern(parse_into_kind(r#"substring:"foo""#)?), - ("substring", ExpressionKind::String("foo".to_owned())) - ); - assert_eq!( - unwrap_pattern(parse_into_kind("exact:foo")?), - ("exact", ExpressionKind::Identifier("foo")) - ); - assert_eq!( - parse_into_kind(r#""exact:foo""#), - Ok(ExpressionKind::String("exact:foo".to_owned())) - ); - // Symbol-like value expressions - assert_eq!( - unwrap_pattern(parse_into_kind("x:@")?), - ("x", ExpressionKind::AtCurrentWorkspace) - ); - assert_eq!( - unwrap_pattern(parse_into_kind("x:y@z")?), - ( - "x", - ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "y".into(), - remote: "z".into(), - }) - ) - ); - - assert_eq!( - parse_normalized(r#"(exact:"foo" )"#), - parse_normalized(r#"(exact:"foo")"#), - ); - assert_eq!( - unwrap_pattern(parse_into_kind(r#"exact:'\'"#)?), - ("exact", ExpressionKind::String(r"\".to_owned())) - ); - - // Whitespace isn't allowed in between - assert_matches!( - parse_into_kind("exact: foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_matches!( - parse_into_kind("exact :foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - // Whitespace is allowed in parenthesized value expression - assert_eq!( - parse_normalized("exact:( 'foo' )"), - parse_normalized("exact:'foo'"), - ); - - // Functions are allowed - assert_eq!(parse_normalized("x:f(y)"), parse_normalized("x:(f(y))")); - // Neighbor postfix operations are also allowed - assert_eq!(parse_normalized("x:@-+"), parse_normalized("x:((@-)+)")); - // Ranges have lower binding strength because we wouldn't want to parse - // x::: as x:(::) - assert_eq!(parse_normalized("x:y::z"), parse_normalized("(x:y)::(z)")); - assert_matches!( - parse_into_kind("x:::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - // Logical operators have lower binding strength - assert_eq!(parse_normalized("x:y&z"), parse_normalized("(x:y)&(z)")); - assert_matches!( - parse_into_kind("x:~y"), // (x:) ~ (y) - Err(RevsetParseErrorKind::NotPostfixOperator { .. }) - ); - - // Pattern prefix is like (type)x cast, so is evaluated from right - assert_eq!(parse_normalized("x:y:z"), parse_normalized("x:(y:z)")); - Ok(()) - } - - #[test] - fn test_parse_symbol_explicitly() { - assert_matches!(parse_symbol("").as_deref(), Err(_)); - // empty string could be a valid ref name, but it would be super - // confusing if identifier was empty. - assert_matches!(parse_symbol("''").as_deref(), Err(_)); - - assert_matches!(parse_symbol("foo.bar").as_deref(), Ok("foo.bar")); - assert_matches!(parse_symbol("foo@bar").as_deref(), Err(_)); - assert_matches!(parse_symbol("foo bar").as_deref(), Err(_)); - - assert_matches!(parse_symbol("'foo bar'").as_deref(), Ok("foo bar")); - assert_matches!(parse_symbol(r#""foo\tbar""#).as_deref(), Ok("foo\tbar")); - - // leading/trailing whitespace is NOT ignored. - assert_matches!(parse_symbol(" foo").as_deref(), Err(_)); - assert_matches!(parse_symbol("foo ").as_deref(), Err(_)); - - // (foo) could be parsed as a symbol "foo", but is rejected because user - // might expect a literal "(foo)". - assert_matches!(parse_symbol("(foo)").as_deref(), Err(_)); - } - - #[test] - fn parse_at_workspace_and_remote_symbol() { - // Parse "@" (the current working copy) - assert_eq!(parse_into_kind("@"), Ok(ExpressionKind::AtCurrentWorkspace)); - assert_eq!( - parse_into_kind("main@"), - Ok(ExpressionKind::AtWorkspace("main".to_owned())) - ); - assert_eq!( - parse_into_kind("main@origin"), - Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "main".into(), - remote: "origin".into() - })) - ); - - // Quoted component in @ expression - assert_eq!( - parse_into_kind(r#""foo bar"@"#), - Ok(ExpressionKind::AtWorkspace("foo bar".to_owned())) - ); - assert_eq!( - parse_into_kind(r#""foo bar"@origin"#), - Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "foo bar".into(), - remote: "origin".into() - })) - ); - assert_eq!( - parse_into_kind(r#"main@"foo bar""#), - Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "main".into(), - remote: "foo bar".into() - })) - ); - assert_eq!( - parse_into_kind(r#"'foo bar'@'bar baz'"#), - Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "foo bar".into(), - remote: "bar baz".into() - })) - ); - - // Quoted "@" is not interpreted as a working copy or remote symbol - assert_eq!( - parse_into_kind(r#""@""#), - Ok(ExpressionKind::String("@".to_owned())) - ); - assert_eq!( - parse_into_kind(r#""main@""#), - Ok(ExpressionKind::String("main@".to_owned())) - ); - assert_eq!( - parse_into_kind(r#""main@origin""#), - Ok(ExpressionKind::String("main@origin".to_owned())) - ); - - // Non-ASCII name - assert_eq!( - parse_into_kind("柔術@"), - Ok(ExpressionKind::AtWorkspace("柔術".to_owned())) - ); - assert_eq!( - parse_into_kind("柔@術"), - Ok(ExpressionKind::RemoteSymbol(RemoteRefSymbolBuf { - name: "柔".into(), - remote: "術".into() - })) - ); - } - - #[test] - fn test_parse_function_call() -> TestResult { - fn unwrap_function_call(node: ExpressionNode<'_>) -> Box> { - match node.kind { - ExpressionKind::FunctionCall(function) => function, - _ => panic!("unexpected expression: {node:?}"), - } - } - - // Space is allowed around infix operators and function arguments - assert_eq!( - parse_normalized( - " description( arg1 ) ~ file( arg1 , arg2 ) ~ visible_heads( ) ", - ), - parse_normalized("(description(arg1) ~ file(arg1, arg2)) ~ visible_heads()"), - ); - // Space is allowed around keyword arguments - assert_eq!( - parse_normalized("remote_bookmarks( remote = foo )"), - parse_normalized("remote_bookmarks(remote=foo)"), - ); - - // Trailing comma isn't allowed for empty argument - assert!(parse_into_kind("bookmarks(,)").is_err()); - // Trailing comma is allowed for the last argument - assert_eq!( - parse_normalized("bookmarks(a,)"), - parse_normalized("bookmarks(a)") - ); - assert_eq!( - parse_normalized("bookmarks(a , )"), - parse_normalized("bookmarks(a)") - ); - assert!(parse_into_kind("bookmarks(,a)").is_err()); - assert!(parse_into_kind("bookmarks(a,,)").is_err()); - assert!(parse_into_kind("bookmarks(a , , )").is_err()); - assert_eq!( - parse_normalized("file(a,b,)"), - parse_normalized("file(a, b)") - ); - assert!(parse_into_kind("file(a,,b)").is_err()); - assert_eq!( - parse_normalized("remote_bookmarks(a,remote=b , )"), - parse_normalized("remote_bookmarks(a, remote=b)"), - ); - assert!(parse_into_kind("remote_bookmarks(a,,remote=b)").is_err()); - - // Expression span - let function = unwrap_function_call(parse_program("foo( a, (b) , ~(c), d = (e) )")?); - assert_eq!(function.name_span.as_str(), "foo"); - assert_eq!(function.args_span.as_str(), "a, (b) , ~(c), d = (e)"); - assert_eq!(function.args[0].span.as_str(), "a"); - assert_eq!(function.args[1].span.as_str(), "(b)"); - assert_eq!(function.args[2].span.as_str(), "~(c)"); - assert_eq!(function.keyword_args[0].name_span.as_str(), "d"); - assert_eq!(function.keyword_args[0].value.span.as_str(), "(e)"); - Ok(()) - } - - #[test] - fn test_parse_revset_alias_symbol_decl() { - let mut aliases_map = RevsetAliasesMap::new(); - // Working copy or remote symbol cannot be used as an alias name. - assert!(aliases_map.insert("@", "none()", None).is_err()); - assert!(aliases_map.insert("a@", "none()", None).is_err()); - assert!(aliases_map.insert("a@b", "none()", None).is_err()); - // Non-ASCII character isn't allowed in alias symbol. This rule can be - // relaxed if needed. - assert!(aliases_map.insert("柔術", "none()", None).is_err()); - } - - #[test] - fn test_parse_revset_alias_pattern_decl() -> TestResult { - let mut aliases_map = RevsetAliasesMap::new(); - assert!(aliases_map.insert("foo:", "none()", None).is_err()); - assert_eq!(aliases_map.pattern_names().count(), 0); - - aliases_map.insert("bar:baz", "'bar pattern'", None)?; - assert_eq!(aliases_map.pattern_names().count(), 1); - let (id, param, defn, _doc) = aliases_map.get_pattern("bar").unwrap(); - assert_eq!(id, AliasId::Pattern("bar", "baz")); - assert_eq!(param, "baz"); - assert_eq!(defn, "'bar pattern'"); - - // Non-ASCII character isn't allowed. This rule can be relaxed if - // needed. - assert!(aliases_map.insert("柔術:x", "none()", None).is_err()); - assert!(aliases_map.insert("x:柔術", "none()", None).is_err()); - Ok(()) - } - - #[test] - fn test_parse_revset_alias_func_decl() -> TestResult { - let mut aliases_map = RevsetAliasesMap::new(); - assert!( - aliases_map - .insert("5func()", r#""is function 0""#, None) - .is_err() - ); - aliases_map.insert("func()", r#""is function 0""#, None)?; - aliases_map.insert("func(a, b)", r#""is function 2""#, None)?; - aliases_map.insert("func(a)", r#""is function a""#, None)?; - aliases_map.insert("func(b)", r#""is function b""#, None)?; - - let (id, params, defn, _doc) = aliases_map.get_function("func", 0).unwrap(); - assert_eq!(id, AliasId::Function("func", &[])); - assert!(params.is_empty()); - assert_eq!(defn, r#""is function 0""#); - - let (id, params, defn, _doc) = aliases_map.get_function("func", 1).unwrap(); - assert_eq!(id, AliasId::Function("func", &["b".to_owned()])); - assert_eq!(params, ["b"]); - assert_eq!(defn, r#""is function b""#); - - let (id, params, defn, _doc) = aliases_map.get_function("func", 2).unwrap(); - assert_eq!( - id, - AliasId::Function("func", &["a".to_owned(), "b".to_owned()]) - ); - assert_eq!(params, ["a", "b"]); - assert_eq!(defn, r#""is function 2""#); - - assert!(aliases_map.get_function("func", 3).is_none()); - Ok(()) - } - - #[test] - fn test_parse_revset_alias_formal_parameter() { - let mut aliases_map = RevsetAliasesMap::new(); - // Working copy or remote symbol cannot be used as an parameter name. - assert!(aliases_map.insert("f(@)", "none()", None).is_err()); - assert!(aliases_map.insert("f(a@)", "none()", None).is_err()); - assert!(aliases_map.insert("f(a@b)", "none()", None).is_err()); - // Trailing comma isn't allowed for empty parameter - assert!(aliases_map.insert("f(,)", "none()", None).is_err()); - // Trailing comma is allowed for the last parameter - assert!(aliases_map.insert("g(a,)", "none()", None).is_ok()); - assert!(aliases_map.insert("h(a , )", "none()", None).is_ok()); - assert!(aliases_map.insert("i(,a)", "none()", None).is_err()); - assert!(aliases_map.insert("j(a,,)", "none()", None).is_err()); - assert!(aliases_map.insert("k(a , , )", "none()", None).is_err()); - assert!(aliases_map.insert("l(a,b,)", "none()", None).is_ok()); - assert!(aliases_map.insert("m(a,,b)", "none()", None).is_err()); - } - - #[test] - fn test_parse_revset_compat_operator() { - assert_eq!( - parse_into_kind(":foo"), - Err(RevsetParseErrorKind::NotPrefixOperator { - op: ":".to_owned(), - similar_op: "::".to_owned(), - description: "ancestors".to_owned(), - }) - ); - assert_eq!( - parse_into_kind("foo^"), - Err(RevsetParseErrorKind::NotPostfixOperator { - op: "^".to_owned(), - similar_op: "-".to_owned(), - description: "parents".to_owned(), - }) - ); - assert_eq!( - parse_into_kind("foo + bar"), - Err(RevsetParseErrorKind::NotInfixOperator { - op: "+".to_owned(), - similar_op: "|".to_owned(), - description: "union".to_owned(), - }) - ); - assert_eq!( - parse_into_kind("foo - bar"), - Err(RevsetParseErrorKind::NotInfixOperator { - op: "-".to_owned(), - similar_op: "~".to_owned(), - description: "difference".to_owned(), - }) - ); - } - - #[test] - fn test_parse_revset_operator_combinations() { - // Parse repeated "parents" operator - assert_eq!(parse_normalized("foo---"), parse_normalized("((foo-)-)-")); - // Parse repeated "children" operator - assert_eq!(parse_normalized("foo+++"), parse_normalized("((foo+)+)+")); - // Set operator associativity/precedence - assert_eq!(parse_normalized("~x|y"), parse_normalized("(~x)|y")); - assert_eq!(parse_normalized("x&~y"), parse_normalized("x&(~y)")); - assert_eq!(parse_normalized("x~~y"), parse_normalized("x~(~y)")); - assert_eq!(parse_normalized("x~~~y"), parse_normalized("x~(~(~y))")); - assert_eq!(parse_normalized("~x::y"), parse_normalized("~(x::y)")); - assert_eq!(parse_normalized("x|y|z"), parse_normalized("(x|y)|z")); - assert_eq!(parse_normalized("x&y|z"), parse_normalized("(x&y)|z")); - assert_eq!(parse_normalized("x|y&z"), parse_normalized("x|(y&z)")); - assert_eq!(parse_normalized("x|y~z"), parse_normalized("x|(y~z)")); - assert_eq!(parse_normalized("::&.."), parse_normalized("(::)&(..)")); - // Parse repeated "ancestors"/"descendants"/"dag range"/"range" operators - assert_eq!( - parse_into_kind("::foo::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind(":::foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("::::foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo:::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo::::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo:::bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo::::bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("::foo::bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo::bar::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("::::"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("....foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo...."), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo.....bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("..foo..bar"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("foo..bar.."), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("...."), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("::.."), - Err(RevsetParseErrorKind::SyntaxError) - ); - // Parse combinations of "parents"/"children" operators and the range operators. - // The former bind more strongly. - assert_eq!(parse_normalized("foo-+"), parse_normalized("(foo-)+")); - assert_eq!(parse_normalized("foo-::"), parse_normalized("(foo-)::")); - assert_eq!(parse_normalized("::foo+"), parse_normalized("::(foo+)")); - assert_eq!( - parse_into_kind("::-"), - Err(RevsetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind("..+"), - Err(RevsetParseErrorKind::SyntaxError) - ); - } - - #[test] - fn test_parse_revset_function() { - assert_matches!( - parse_into_kind("parents(foo)"), - Ok(ExpressionKind::FunctionCall(_)) - ); - assert_eq!( - parse_normalized("parents((foo))"), - parse_normalized("parents(foo)"), - ); - assert_eq!( - parse_into_kind("parents(foo"), - Err(RevsetParseErrorKind::SyntaxError) - ); - } - - #[test] - fn test_expand_symbol_alias() { - assert_eq!( - with_aliases([("AB", "a&b")]).parse_normalized("AB|c"), - parse_normalized("(a&b)|c") - ); - assert_eq!( - with_aliases([("AB", "a|b")]).parse_normalized("AB::heads(AB)"), - parse_normalized("(a|b)::heads(a|b)") - ); - - // Not string substitution 'a&b|c', but tree substitution. - assert_eq!( - with_aliases([("BC", "b|c")]).parse_normalized("a&BC"), - parse_normalized("a&(b|c)") - ); - - // String literal should not be substituted with alias. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized(r#"A|"A"|'A'"#), - parse_normalized("a|'A'|'A'") - ); - - // Kind of string pattern should not be substituted, which is similar to - // function name. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("author(A:b)"), - parse_normalized("author(A:b)") - ); - - // Value of string pattern can be substituted if it's an identifier. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("author(exact:A)"), - parse_normalized("author(exact:a)") - ); - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("author(exact:'A')"), - parse_normalized("author(exact:'A')") - ); - - // Part of @ symbol cannot be substituted. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("A@"), - parse_normalized("A@") - ); - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("A@b"), - parse_normalized("A@b") - ); - assert_eq!( - with_aliases([("B", "b")]).parse_normalized("a@B"), - parse_normalized("a@B") - ); - - // Multi-level substitution. - assert_eq!( - with_aliases([("A", "BC"), ("BC", "b|C"), ("C", "c")]).parse_normalized("A"), - parse_normalized("b|c") - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - *with_aliases([("A", "A")]).parse("A").unwrap_err().kind, - RevsetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - assert_eq!( - *with_aliases([("A", "B"), ("B", "b|C"), ("C", "c|B")]) - .parse("A") - .unwrap_err() - .kind, - RevsetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - - // Error in alias definition. - assert_eq!( - *with_aliases([("A", "a(")]).parse("A").unwrap_err().kind, - RevsetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - } - - #[test] - fn test_expand_pattern_alias() { - assert_eq!( - with_aliases([("P:x", "x")]).parse_normalized("P:a"), - parse_normalized("a") - ); - - // Argument should be resolved in the current scope. - assert_eq!( - with_aliases([("P:x", "x|a")]).parse_normalized("P:x"), - parse_normalized("x|a") - ); - // P:a -> (Q:a)&y -> (x|a)&y - assert_eq!( - with_aliases([("P:x", "(Q:x)&y"), ("Q:y", "x|y")]).parse_normalized("P:a"), - parse_normalized("(x|a)&y") - ); - - // Pattern parameter should precede the symbol alias. - assert_eq!( - with_aliases([("P:X", "X"), ("X", "x")]).parse_normalized("(P:a)|X"), - parse_normalized("a|x") - ); - - // Pattern parameter shouldn't be expanded in symbol alias. - assert_eq!( - with_aliases([("P:x", "x|A"), ("A", "x")]).parse_normalized("P:a"), - parse_normalized("a|x") - ); - - // String literal should not be substituted with pattern parameter. - assert_eq!( - with_aliases([("P:x", "x|'x'")]).parse_normalized("P:a"), - parse_normalized("a|'x'") - ); - - // Pattern and symbol aliases reside in separate namespaces. - assert_eq!( - with_aliases([("A:x", "A"), ("A", "a")]).parse_normalized("A:x"), - parse_normalized("a") - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - *with_aliases([("P:x", "Q:x"), ("Q:x", "R:x"), ("R:x", "P:x")]) - .parse("P:a") - .unwrap_err() - .kind, - RevsetParseErrorKind::InAliasExpansion("P:x".to_owned()) - ); - } - - #[test] - fn test_expand_function_alias() { - assert_eq!( - with_aliases([("F( )", "a")]).parse_normalized("F()"), - parse_normalized("a") - ); - assert_eq!( - with_aliases([("F( x )", "x")]).parse_normalized("F(a)"), - parse_normalized("a") - ); - assert_eq!( - with_aliases([("F( x, y )", "x|y")]).parse_normalized("F(a, b)"), - parse_normalized("a|b") - ); - - // Not recursion because functions are overloaded by arity. - assert_eq!( - with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "x|y")]).parse_normalized("F(a)"), - parse_normalized("a|b") - ); - - // Arguments should be resolved in the current scope. - assert_eq!( - with_aliases([("F(x,y)", "x|y")]).parse_normalized("F(a::y,b::x)"), - parse_normalized("(a::y)|(b::x)") - ); - // F(a) -> G(a)&y -> (x|a)&y - assert_eq!( - with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(a)"), - parse_normalized("(x|a)&y") - ); - // F(G(a)) -> F(x|a) -> G(x|a)&y -> (x|(x|a))&y - assert_eq!( - with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(G(a))"), - parse_normalized("(x|(x|a))&y") - ); - - // Function parameter should precede the symbol alias. - assert_eq!( - with_aliases([("F(X)", "X"), ("X", "x")]).parse_normalized("F(a)|X"), - parse_normalized("a|x") - ); - - // Function parameter shouldn't be expanded in symbol alias. - assert_eq!( - with_aliases([("F(x)", "x|A"), ("A", "x")]).parse_normalized("F(a)"), - parse_normalized("a|x") - ); - - // String literal should not be substituted with function parameter. - assert_eq!( - with_aliases([("F(x)", r#"x|"x""#)]).parse_normalized("F(a)"), - parse_normalized("a|'x'") - ); - - // Function and symbol aliases reside in separate namespaces. - assert_eq!( - with_aliases([("A()", "A"), ("A", "a")]).parse_normalized("A()"), - parse_normalized("a") - ); - - // Invalid number of arguments. - assert_eq!( - *with_aliases([("F()", "x")]).parse("F(a)").unwrap_err().kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Expected 0 arguments".to_owned() - } - ); - assert_eq!( - *with_aliases([("F(x)", "x")]).parse("F()").unwrap_err().kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Expected 1 arguments".to_owned() - } - ); - assert_eq!( - *with_aliases([("F(x,y)", "x|y")]) - .parse("F(a,b,c)") - .unwrap_err() - .kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Expected 2 arguments".to_owned() - } - ); - assert_eq!( - *with_aliases([("F(x)", "x"), ("F(x,y)", "x|y")]) - .parse("F()") - .unwrap_err() - .kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Expected 1 to 2 arguments".to_owned() - } - ); - assert_eq!( - *with_aliases([("F()", "x"), ("F(x,y)", "x|y")]) - .parse("F(a)") - .unwrap_err() - .kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Expected 0, 2 arguments".to_owned() - } - ); - - // Keyword argument isn't supported for now. - assert_eq!( - *with_aliases([("F(x)", "x")]) - .parse("F(x=y)") - .unwrap_err() - .kind, - RevsetParseErrorKind::InvalidFunctionArguments { - name: "F".to_owned(), - message: "Unexpected keyword arguments".to_owned() - } - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - *with_aliases([("F(x)", "G(x)"), ("G(x)", "H(x)"), ("H(x)", "F(x)")]) - .parse("F(a)") - .unwrap_err() - .kind, - RevsetParseErrorKind::InAliasExpansion("F(x)".to_owned()) - ); - assert_eq!( - *with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "F(x|y)")]) - .parse("F(a)") - .unwrap_err() - .kind, - RevsetParseErrorKind::InAliasExpansion("F(x)".to_owned()) - ); - } - - #[test] - fn test_expand_with_locals() { - // Local variable should precede the symbol alias. - assert_eq!( - with_aliases([("A", "symbol")]) - .set_local("A", "local") - .parse_normalized("A"), - parse_normalized("local") - ); - - // Local variable shouldn't be expanded within aliases. - assert_eq!( - with_aliases([("B", "A"), ("F(x)", "x&A")]) - .set_local("A", "a") - .parse_normalized("A|B|F(A)"), - parse_normalized("a|A|(a&A)") - ); - } -} +// This is needed so we export the same interface as usual without people +// noticing that we moved everything to the jj-core crate. +#![expect(unused_imports)] + +pub use jj_core::revset_parser::BinaryOp; +pub use jj_core::revset_parser::ExpressionKind; +pub use jj_core::revset_parser::ExpressionNode; +pub use jj_core::revset_parser::FunctionCallNode; +pub use jj_core::revset_parser::PatternNode; +pub use jj_core::revset_parser::RevsetAliasParser; +pub use jj_core::revset_parser::RevsetAliasesMap; +pub use jj_core::revset_parser::RevsetDiagnostics; +pub use jj_core::revset_parser::RevsetParseError; +pub use jj_core::revset_parser::RevsetParseErrorKind; +pub use jj_core::revset_parser::Rule; +pub use jj_core::revset_parser::UnaryOp; +pub use jj_core::revset_parser::catch_aliases; +pub use jj_core::revset_parser::expect_literal; +pub use jj_core::revset_parser::expect_string_literal; +pub use jj_core::revset_parser::expect_string_pattern; +pub use jj_core::revset_parser::is_identifier; +pub use jj_core::revset_parser::parse_program; +pub use jj_core::revset_parser::parse_symbol; From ad19c7132be13a15d808879c24a0e8d68093212f Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Mon, 8 Jun 2026 18:02:43 +0200 Subject: [PATCH 7/8] core: Move the `FilesetParser` to it So `jj-core` users also have access to it, since we already did the same thing for the `RevsetParser`. This is part of building a new `jj-core` crate. Part of #6284 --- lib/{ => core}/src/fileset.pest | 0 lib/core/src/fileset_parser.rs | 1395 +++++++++++++++++++++++++++++++ lib/core/src/lib.rs | 1 + lib/src/fileset_parser.rs | 1370 +----------------------------- 4 files changed, 1418 insertions(+), 1348 deletions(-) rename lib/{ => core}/src/fileset.pest (100%) create mode 100644 lib/core/src/fileset_parser.rs diff --git a/lib/src/fileset.pest b/lib/core/src/fileset.pest similarity index 100% rename from lib/src/fileset.pest rename to lib/core/src/fileset.pest diff --git a/lib/core/src/fileset_parser.rs b/lib/core/src/fileset_parser.rs new file mode 100644 index 00000000000..887898491c1 --- /dev/null +++ b/lib/core/src/fileset_parser.rs @@ -0,0 +1,1395 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Parser for the fileset language. + +// TODO: finish documenting fileset.pest +#![allow(missing_docs)] +use std::error; +use std::sync::LazyLock; + +use itertools::Itertools as _; +use pest::Parser as _; +use pest::iterators::Pair; +use pest::pratt_parser::Assoc; +use pest::pratt_parser::Op; +use pest::pratt_parser::PrattParser; +use pest_derive::Parser; +use thiserror::Error; + +use crate::dsl_util; +use crate::dsl_util::AliasDeclaration; +use crate::dsl_util::AliasDeclarationParser; +use crate::dsl_util::AliasDefinitionParser; +use crate::dsl_util::AliasExpandError; +use crate::dsl_util::AliasExpandableExpression; +use crate::dsl_util::AliasId; +use crate::dsl_util::AliasesMap; +use crate::dsl_util::Diagnostics; +use crate::dsl_util::ExpressionFolder; +use crate::dsl_util::FoldableExpression; +use crate::dsl_util::InvalidArguments; +use crate::dsl_util::StringLiteralParser; + +/// A parser for the Fileset language. +#[derive(Parser)] +#[grammar = "fileset.pest"] +struct FilesetParser; + +const STRING_LITERAL_PARSER: StringLiteralParser = StringLiteralParser { + content_rule: Rule::string_content, + escape_rule: Rule::string_escape, +}; + +impl Rule { + fn to_symbol(self) -> Option<&'static str> { + match self { + Self::EOI => None, + Self::whitespace => None, + Self::identifier => None, + Self::strict_identifier_part => None, + Self::strict_identifier => None, + Self::bare_string => None, + Self::string_escape => None, + Self::string_content_char => None, + Self::string_content => None, + Self::string_literal => None, + Self::raw_string_content => None, + Self::raw_string_literal => None, + Self::pattern_kind_op => Some(":"), + Self::negate_op => Some("~"), + Self::union_op => Some("|"), + Self::intersection_op => Some("&"), + Self::difference_op => Some("~"), + Self::prefix_ops => None, + Self::infix_ops => None, + Self::function => None, + Self::function_name => None, + Self::function_arguments => None, + Self::formal_parameters => None, + Self::pattern => None, + Self::bare_string_pattern => None, + Self::primary => None, + Self::expression => None, + Self::program => None, + Self::program_or_bare_string => None, + Self::function_alias_declaration => None, + Self::pattern_alias_declaration => None, + Self::alias_declaration => None, + } + } +} + +/// Manages diagnostic messages emitted during fileset parsing and name +/// resolution. +pub type FilesetDiagnostics = Diagnostics; + +/// Result of fileset parsing and name resolution. +pub type FilesetParseResult = Result; + +/// Error occurred during fileset parsing and name resolution. +#[derive(Debug, Error)] +#[error("{pest_error}")] +pub struct FilesetParseError { + kind: FilesetParseErrorKind, + pest_error: Box>, + source: Option>, +} + +/// Categories of fileset parsing and name resolution error. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum FilesetParseErrorKind { + /// A syntax error occurred. + #[error("Syntax error")] + SyntaxError, + /// No function for the given name exists. + #[error("Function `{name}` doesn't exist")] + NoSuchFunction { + /// The name of the passed function. + name: String, + /// Potential candidates matching the given name. + candidates: Vec, + }, + /// A fileset function received invalid arguments. + #[error("Function `{name}`: {message}")] + InvalidArguments { + /// The name of the function. + name: String, + /// An additional message to provide more context. + message: String, + }, + /// A redefinition of a function parameter was detected. + #[error("Redefinition of function parameter")] + RedefinedFunctionParameter, + /// An erroneous expression was encountered. + #[error("{0}")] + Expression(String), + /// A fileset expression failed to expand correctly. + #[error("In alias `{0}`")] + InAliasExpansion(String), + /// A function parameter failed to expand correctly. + #[error("In function parameter `{0}`")] + InParameterExpansion(String), + /// An Alias expanded recursively. + #[error("Alias `{0}` expanded recursively")] + RecursiveAlias(String), +} + +impl FilesetParseError { + /// Create a new `FilesetParseError` from with the given `kind` and `span`. + pub fn new(kind: FilesetParseErrorKind, span: pest::Span<'_>) -> Self { + let message = kind.to_string(); + let pest_error = Box::new(pest::error::Error::new_from_span( + pest::error::ErrorVariant::CustomError { message }, + span, + )); + Self { + kind, + pest_error, + source: None, + } + } + + /// Add an additional error source to the `FilesetParseError`. + pub fn with_source(mut self, source: impl Into>) -> Self { + self.source = Some(source.into()); + self + } + + /// Some other expression error. + pub fn expression(message: impl Into, span: pest::Span<'_>) -> Self { + Self::new(FilesetParseErrorKind::Expression(message.into()), span) + } + + /// Category of the underlying error. + pub fn kind(&self) -> &FilesetParseErrorKind { + &self.kind + } +} + +impl AliasExpandError for FilesetParseError { + fn invalid_arguments(err: InvalidArguments<'_>) -> Self { + err.into() + } + + fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self { + Self::new(FilesetParseErrorKind::RecursiveAlias(id.to_string()), span) + } + + fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self { + let kind = match id { + AliasId::Symbol(_) | AliasId::Pattern(..) | AliasId::Function(..) => { + FilesetParseErrorKind::InAliasExpansion(id.to_string()) + } + AliasId::Parameter(_) => FilesetParseErrorKind::InParameterExpansion(id.to_string()), + }; + Self::new(kind, span).with_source(self) + } +} + +impl From> for FilesetParseError { + fn from(err: pest::error::Error) -> Self { + Self { + kind: FilesetParseErrorKind::SyntaxError, + pest_error: Box::new(rename_rules_in_pest_error(err)), + source: None, + } + } +} + +impl From> for FilesetParseError { + fn from(err: InvalidArguments<'_>) -> Self { + let kind = FilesetParseErrorKind::InvalidArguments { + name: err.name.to_owned(), + message: err.message, + }; + Self::new(kind, err.span) + } +} + +fn rename_rules_in_pest_error(err: pest::error::Error) -> pest::error::Error { + err.renamed_rules(|rule| { + rule.to_symbol() + .map(|sym| format!("`{sym}`")) + .unwrap_or_else(|| format!("<{rule:?}>")) + }) +} + +/// Describes the types of Expressions the fileset language supports. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExpressionKind<'i> { + /// A identifier + Identifier(&'i str), + /// A String. + String(String), + /// `:` where `` is usually `Identifier` or `String`. + Pattern(Box>), + /// A urnary expression. + Unary(UnaryOp, Box>), + /// A binary expression with both of its nodes. + Binary(BinaryOp, Box>, Box>), + /// `x | y | ..` + UnionAll(Vec>), + /// A function call. + FunctionCall(Box>), + /// Identity node to preserve the span in the source text. + AliasExpanded(AliasId<'i>, Box>), +} + +impl<'i> FoldableExpression<'i> for ExpressionKind<'i> { + fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result + where + F: ExpressionFolder<'i, Self> + ?Sized, + { + match self { + Self::Identifier(name) => folder.fold_identifier(name, span), + Self::String(_) => Ok(self), + Self::Pattern(pattern) => folder.fold_pattern(pattern, span), + Self::Unary(op, arg) => { + let arg = Box::new(folder.fold_expression(*arg)?); + Ok(Self::Unary(op, arg)) + } + Self::Binary(op, lhs, rhs) => { + let lhs = Box::new(folder.fold_expression(*lhs)?); + let rhs = Box::new(folder.fold_expression(*rhs)?); + Ok(Self::Binary(op, lhs, rhs)) + } + Self::UnionAll(nodes) => { + let nodes = dsl_util::fold_expression_nodes(folder, nodes)?; + Ok(Self::UnionAll(nodes)) + } + Self::FunctionCall(function) => folder.fold_function_call(function, span), + Self::AliasExpanded(id, subst) => { + let subst = Box::new(folder.fold_expression(*subst)?); + Ok(Self::AliasExpanded(id, subst)) + } + } + } +} + +impl<'i> AliasExpandableExpression<'i> for ExpressionKind<'i> { + fn identifier(name: &'i str) -> Self { + Self::Identifier(name) + } + + fn pattern(pattern: Box>) -> Self { + Self::Pattern(pattern) + } + + fn function_call(function: Box>) -> Self { + Self::FunctionCall(function) + } + + fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self { + Self::AliasExpanded(id, subst) + } +} + +/// A urnary operation in the Fileset language. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum UnaryOp { + /// `~` + Negate, +} + +/// A binary operation in the Fileset language. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum BinaryOp { + /// `&` + Intersection, + /// `~` + Difference, +} + +/// A Expression in the Fileset language. +pub type ExpressionNode<'i> = dsl_util::ExpressionNode<'i, ExpressionKind<'i>>; +/// A FunctionCall in the Fileset language. +pub type FunctionCallNode<'i> = dsl_util::FunctionCallNode<'i, ExpressionKind<'i>>; +/// A Pattern in the Fileset language. +pub type PatternNode<'i> = dsl_util::PatternNode<'i, ExpressionKind<'i>>; + +fn union_nodes<'i>(lhs: ExpressionNode<'i>, rhs: ExpressionNode<'i>) -> ExpressionNode<'i> { + let span = lhs.span.start_pos().span(&rhs.span.end_pos()); + let expr = match lhs.kind { + // Flatten "x | y | z" to save recursion stack. Machine-generated query + // might have long chain of unions. + ExpressionKind::UnionAll(mut nodes) => { + nodes.push(rhs); + ExpressionKind::UnionAll(nodes) + } + _ => ExpressionKind::UnionAll(vec![lhs, rhs]), + }; + ExpressionNode::new(expr, span) +} + +fn parse_function_call_node(pair: Pair) -> FilesetParseResult { + assert_eq!(pair.as_rule(), Rule::function); + let [name_pair, args_pair] = pair.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), Rule::function_name); + assert_eq!(args_pair.as_rule(), Rule::function_arguments); + let name_span = name_pair.as_span(); + let args_span = args_pair.as_span(); + let name = name_pair.as_str(); + let args = args_pair + .into_inner() + .map(parse_expression_node) + .try_collect()?; + Ok(FunctionCallNode { + name, + name_span, + args, + keyword_args: vec![], // unsupported + args_span, + }) +} + +fn parse_as_string_literal(pair: Pair) -> String { + match pair.as_rule() { + Rule::identifier => pair.as_str().to_owned(), + Rule::string_literal => STRING_LITERAL_PARSER.parse(pair.into_inner()), + Rule::raw_string_literal => { + let [content] = pair.into_inner().collect_array().unwrap(); + assert_eq!(content.as_rule(), Rule::raw_string_content); + content.as_str().to_owned() + } + r => panic!("unexpected string literal rule: {r:?}"), + } +} + +fn parse_primary_node(pair: Pair) -> FilesetParseResult { + assert_eq!(pair.as_rule(), Rule::primary); + let span = pair.as_span(); + let first = pair.into_inner().next().unwrap(); + let expr = match first.as_rule() { + // Ignore inner span to preserve parenthesized expression as such. + Rule::expression => parse_expression_node(first)?.kind, + Rule::function => { + let function = Box::new(parse_function_call_node(first)?); + ExpressionKind::FunctionCall(function) + } + Rule::pattern => { + let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); + assert_eq!(lhs.as_rule(), Rule::strict_identifier); + assert_eq!(op.as_rule(), Rule::pattern_kind_op); + let pattern = Box::new(PatternNode { + name: lhs.as_str(), + name_span: lhs.as_span(), + value: parse_primary_node(rhs)?, + }); + ExpressionKind::Pattern(pattern) + } + Rule::identifier => ExpressionKind::Identifier(first.as_str()), + Rule::string_literal | Rule::raw_string_literal => { + ExpressionKind::String(parse_as_string_literal(first)) + } + r => panic!("unexpected primary rule: {r:?}"), + }; + Ok(ExpressionNode::new(expr, span)) +} + +fn parse_expression_node(pair: Pair) -> FilesetParseResult { + assert_eq!(pair.as_rule(), Rule::expression); + static PRATT: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::union_op, Assoc::Left)) + .op(Op::infix(Rule::intersection_op, Assoc::Left) + | Op::infix(Rule::difference_op, Assoc::Left)) + .op(Op::prefix(Rule::negate_op)) + }); + PRATT + .map_primary(parse_primary_node) + .map_prefix(|op, rhs| { + let op_kind = match op.as_rule() { + Rule::negate_op => UnaryOp::Negate, + r => panic!("unexpected prefix operator rule {r:?}"), + }; + let rhs = Box::new(rhs?); + let span = op.as_span().start_pos().span(&rhs.span.end_pos()); + let expr = ExpressionKind::Unary(op_kind, rhs); + Ok(ExpressionNode::new(expr, span)) + }) + .map_infix(|lhs, op, rhs| { + let op_kind = match op.as_rule() { + Rule::union_op => return Ok(union_nodes(lhs?, rhs?)), + Rule::intersection_op => BinaryOp::Intersection, + Rule::difference_op => BinaryOp::Difference, + r => panic!("unexpected infix operator rule {r:?}"), + }; + let lhs = Box::new(lhs?); + let rhs = Box::new(rhs?); + let span = lhs.span.start_pos().span(&rhs.span.end_pos()); + let expr = ExpressionKind::Binary(op_kind, lhs, rhs); + Ok(ExpressionNode::new(expr, span)) + }) + .parse(pair.into_inner()) +} + +/// Parses text into expression tree. No name resolution is made at this stage. +pub fn parse_program(text: &str) -> FilesetParseResult> { + let mut pairs = FilesetParser::parse(Rule::program, text)?; + let first = pairs.next().unwrap(); + parse_expression_node(first) +} + +/// Parses text into expression tree with bare string fallback. No name +/// resolution is made at this stage. +/// +/// If the text can't be parsed as a fileset expression, and if it doesn't +/// contain any operator-like characters, it will be parsed as a file path. +pub fn parse_program_or_bare_string(text: &str) -> FilesetParseResult> { + let mut pairs = FilesetParser::parse(Rule::program_or_bare_string, text)?; + let first = pairs.next().unwrap(); + let span = first.as_span(); + let expr = match first.as_rule() { + Rule::expression => return parse_expression_node(first), + Rule::bare_string_pattern => { + let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); + assert_eq!(lhs.as_rule(), Rule::strict_identifier); + assert_eq!(op.as_rule(), Rule::pattern_kind_op); + assert_eq!(rhs.as_rule(), Rule::bare_string); + let name_span = lhs.as_span(); + let value_span = rhs.as_span(); + let name = lhs.as_str(); + let value_expr = ExpressionKind::String(rhs.as_str().to_owned()); + let pattern = Box::new(PatternNode { + name, + name_span, + value: ExpressionNode::new(value_expr, value_span), + }); + ExpressionKind::Pattern(pattern) + } + Rule::bare_string => ExpressionKind::String(first.as_str().to_owned()), + r => panic!("unexpected program or bare string rule: {r:?}"), + }; + Ok(ExpressionNode::new(expr, span)) +} + +/// Map of fileset aliases. +pub type FilesetAliasesMap = AliasesMap; + +/// A FilesetAliasesParser is responsible for parsing String expressions into +/// Fileset Aliases. +#[derive(Clone, Debug, Default)] +pub struct FilesetAliasParser; + +impl AliasDeclarationParser for FilesetAliasParser { + type Error = FilesetParseError; + + fn parse_declaration(&self, source: &str) -> Result { + let mut pairs = FilesetParser::parse(Rule::alias_declaration, source)?; + let first = pairs.next().unwrap(); + match first.as_rule() { + Rule::strict_identifier => Ok(AliasDeclaration::Symbol(first.as_str().to_owned())), + Rule::pattern_alias_declaration => { + let [name_pair, op, param_pair] = first.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), Rule::strict_identifier); + assert_eq!(op.as_rule(), Rule::pattern_kind_op); + assert_eq!(param_pair.as_rule(), Rule::strict_identifier); + let name = name_pair.as_str().to_owned(); + let param = param_pair.as_str().to_owned(); + Ok(AliasDeclaration::Pattern(name, param)) + } + Rule::function_alias_declaration => { + let [name_pair, params_pair] = first.into_inner().collect_array().unwrap(); + assert_eq!(name_pair.as_rule(), Rule::function_name); + assert_eq!(params_pair.as_rule(), Rule::formal_parameters); + let name = name_pair.as_str().to_owned(); + let params_span = params_pair.as_span(); + let params = params_pair + .into_inner() + .map(|pair| match pair.as_rule() { + Rule::strict_identifier => pair.as_str().to_owned(), + r => panic!("unexpected formal parameter rule {r:?}"), + }) + .collect_vec(); + if params.iter().all_unique() { + Ok(AliasDeclaration::Function(name, params)) + } else { + Err(FilesetParseError::new( + FilesetParseErrorKind::RedefinedFunctionParameter, + params_span, + )) + } + } + r => panic!("unexpected alias declaration rule {r:?}"), + } + } +} + +impl AliasDefinitionParser for FilesetAliasParser { + type Output<'i> = ExpressionKind<'i>; + type Error = FilesetParseError; + + fn parse_definition<'i>(&self, source: &'i str) -> Result, Self::Error> { + parse_program(source) + } +} + +/// Expand the aliases in `node` and `aliases_map`. +pub fn expand_aliases<'i>( + node: ExpressionNode<'i>, + aliases_map: &'i FilesetAliasesMap, +) -> FilesetParseResult> { + dsl_util::expand_aliases(node, aliases_map) +} + +/// Expect a String literal of `type_name` in `node`. +pub fn expect_string_literal<'a>( + type_name: &str, + node: &'a ExpressionNode<'_>, +) -> FilesetParseResult<&'a str> { + catch_aliases_no_diagnostics(node, |node| match &node.kind { + ExpressionKind::Identifier(name) => Ok(*name), + ExpressionKind::String(name) => Ok(name), + _ => Err(FilesetParseError::expression( + format!("Expected {type_name}"), + node.span, + )), + }) +} + +/// Applies the given function to the innermost `node` by unwrapping alias +/// expansion nodes. Appends alias expansion stack to error and diagnostics. +pub fn catch_aliases<'a, 'i, T>( + diagnostics: &mut FilesetDiagnostics, + node: &'a ExpressionNode<'i>, + f: impl FnOnce(&mut FilesetDiagnostics, &'a ExpressionNode<'i>) -> Result, +) -> Result { + let (node, stack) = skip_aliases(node); + if stack.is_empty() { + f(diagnostics, node) + } else { + let mut inner_diagnostics = FilesetDiagnostics::new(); + let result = f(&mut inner_diagnostics, node); + diagnostics.extend_with(inner_diagnostics, |diag| attach_aliases_err(diag, &stack)); + result.map_err(|err| attach_aliases_err(err, &stack)) + } +} + +fn catch_aliases_no_diagnostics<'a, 'i, T>( + node: &'a ExpressionNode<'i>, + f: impl FnOnce(&'a ExpressionNode<'i>) -> Result, +) -> Result { + let (node, stack) = skip_aliases(node); + f(node).map_err(|err| attach_aliases_err(err, &stack)) +} + +fn skip_aliases<'a, 'i>( + mut node: &'a ExpressionNode<'i>, +) -> (&'a ExpressionNode<'i>, Vec<(AliasId<'i>, pest::Span<'i>)>) { + let mut stack = Vec::new(); + while let ExpressionKind::AliasExpanded(id, subst) = &node.kind { + stack.push((*id, node.span)); + node = subst; + } + (node, stack) +} + +fn attach_aliases_err( + err: FilesetParseError, + stack: &[(AliasId<'_>, pest::Span<'_>)], +) -> FilesetParseError { + stack + .iter() + .rfold(err, |err, &(id, span)| err.within_alias_expansion(id, span)) +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + + use super::*; + use crate::dsl_util::KeywordArgument; + use crate::tests::TestResult; + + #[derive(Debug)] + struct WithFilesetAliasesMap { + aliases_map: FilesetAliasesMap, + } + + impl WithFilesetAliasesMap { + fn parse<'i>(&'i self, text: &'i str) -> FilesetParseResult> { + let node = parse_program(text)?; + expand_aliases(node, &self.aliases_map) + } + + fn parse_normalized<'i>(&'i self, text: &'i str) -> ExpressionNode<'i> { + normalize_tree(self.parse(text).unwrap()) + } + } + + fn with_aliases( + aliases: impl IntoIterator, impl Into)>, + ) -> WithFilesetAliasesMap { + let mut aliases_map = FilesetAliasesMap::new(); + for (decl, defn) in aliases { + aliases_map.insert(decl, defn, None).unwrap(); + } + WithFilesetAliasesMap { aliases_map } + } + + fn parse_into_kind(text: &str) -> Result, FilesetParseErrorKind> { + parse_program(text) + .map(|node| node.kind) + .map_err(|err| err.kind) + } + + fn parse_maybe_bare_into_kind(text: &str) -> Result, FilesetParseErrorKind> { + parse_program_or_bare_string(text) + .map(|node| node.kind) + .map_err(|err| err.kind) + } + + fn parse_normalized(text: &str) -> ExpressionNode<'_> { + normalize_tree(parse_program(text).unwrap()) + } + + fn parse_maybe_bare_normalized(text: &str) -> ExpressionNode<'_> { + normalize_tree(parse_program_or_bare_string(text).unwrap()) + } + + /// Drops auxiliary data from parsed tree so it can be compared with other. + fn normalize_tree(node: ExpressionNode) -> ExpressionNode { + fn empty_span() -> pest::Span<'static> { + pest::Span::new("", 0, 0).unwrap() + } + + fn normalize_list(nodes: Vec) -> Vec { + nodes.into_iter().map(normalize_tree).collect() + } + + fn normalize_function_call(function: FunctionCallNode) -> FunctionCallNode { + FunctionCallNode { + name: function.name, + name_span: empty_span(), + args: normalize_list(function.args), + keyword_args: function + .keyword_args + .into_iter() + .map(|arg| KeywordArgument { + name: arg.name, + name_span: empty_span(), + value: normalize_tree(arg.value), + }) + .collect(), + args_span: empty_span(), + } + } + + let normalized_kind = match node.kind { + ExpressionKind::Identifier(_) | ExpressionKind::String(_) => node.kind, + ExpressionKind::Pattern(pattern) => { + let pattern = Box::new(PatternNode { + name: pattern.name, + name_span: empty_span(), + value: normalize_tree(pattern.value), + }); + ExpressionKind::Pattern(pattern) + } + ExpressionKind::Unary(op, arg) => { + let arg = Box::new(normalize_tree(*arg)); + ExpressionKind::Unary(op, arg) + } + ExpressionKind::Binary(op, lhs, rhs) => { + let lhs = Box::new(normalize_tree(*lhs)); + let rhs = Box::new(normalize_tree(*rhs)); + ExpressionKind::Binary(op, lhs, rhs) + } + ExpressionKind::UnionAll(nodes) => { + let nodes = normalize_list(nodes); + ExpressionKind::UnionAll(nodes) + } + ExpressionKind::FunctionCall(function) => { + let function = Box::new(normalize_function_call(*function)); + ExpressionKind::FunctionCall(function) + } + ExpressionKind::AliasExpanded(_, subst) => normalize_tree(*subst).kind, + }; + ExpressionNode { + kind: normalized_kind, + span: empty_span(), + } + } + + #[test] + fn test_parse_tree_eq() { + assert_eq!( + parse_normalized(r#" foo( x ) | ~bar:"baz" "#), + parse_normalized(r#"(foo(x))|(~(bar:"baz"))"#) + ); + assert_ne!(parse_normalized(r#" foo "#), parse_normalized(r#" "foo" "#)); + } + + #[test] + fn test_parse_invalid_function_name() { + assert_eq!( + parse_into_kind("5foo(x)"), + Err(FilesetParseErrorKind::SyntaxError) + ); + } + + #[test] + fn test_parse_whitespace() { + let ascii_whitespaces: String = ('\x00'..='\x7f') + .filter(char::is_ascii_whitespace) + .collect(); + assert_eq!( + parse_normalized(&format!("{ascii_whitespaces}f()")), + parse_normalized("f()") + ); + } + + #[test] + fn test_parse_identifier() { + assert_eq!( + parse_into_kind("dir/foo-bar_0.baz"), + Ok(ExpressionKind::Identifier("dir/foo-bar_0.baz")) + ); + assert_eq!( + parse_into_kind("cli-reference@.md.snap"), + Ok(ExpressionKind::Identifier("cli-reference@.md.snap")) + ); + assert_eq!( + parse_into_kind("柔術.jj"), + Ok(ExpressionKind::Identifier("柔術.jj")) + ); + assert_eq!( + parse_into_kind(r#"Windows\Path"#), + Ok(ExpressionKind::Identifier(r#"Windows\Path"#)) + ); + assert_eq!( + parse_into_kind("glob*[chars]?"), + Ok(ExpressionKind::Identifier("glob*[chars]?")) + ); + } + + #[test] + fn test_parse_string_literal() { + // "\" escapes + assert_eq!( + parse_into_kind(r#" "\t\r\n\"\\\0\e" "#), + Ok(ExpressionKind::String("\t\r\n\"\\\0\u{1b}".to_owned())), + ); + + // Invalid "\" escape + assert_eq!( + parse_into_kind(r#" "\y" "#), + Err(FilesetParseErrorKind::SyntaxError), + ); + + // Single-quoted raw string + assert_eq!( + parse_into_kind(r#" '' "#), + Ok(ExpressionKind::String("".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" 'a\n' "#), + Ok(ExpressionKind::String(r"a\n".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '\' "#), + Ok(ExpressionKind::String(r"\".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '"' "#), + Ok(ExpressionKind::String(r#"""#.to_owned())), + ); + + // Hex bytes + assert_eq!( + parse_into_kind(r#""\x61\x65\x69\x6f\x75""#), + Ok(ExpressionKind::String("aeiou".to_owned())), + ); + assert_eq!( + parse_into_kind(r#""\xe0\xe8\xec\xf0\xf9""#), + Ok(ExpressionKind::String("àèìðù".to_owned())), + ); + assert_eq!( + parse_into_kind(r#""\x""#), + Err(FilesetParseErrorKind::SyntaxError), + ); + assert_eq!( + parse_into_kind(r#""\xf""#), + Err(FilesetParseErrorKind::SyntaxError), + ); + assert_eq!( + parse_into_kind(r#""\xgg""#), + Err(FilesetParseErrorKind::SyntaxError), + ); + } + + #[test] + fn test_parse_pattern() -> TestResult { + fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { + match kind { + ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), + _ => panic!("unexpected expression: {kind:?}"), + } + } + + assert_eq!( + unwrap_pattern(parse_into_kind(r#" foo:bar "#)?), + ("foo", ExpressionKind::Identifier("bar")) + ); + assert_eq!( + unwrap_pattern(parse_into_kind(" foo:glob*[chars]? ")?), + ("foo", ExpressionKind::Identifier("glob*[chars]?")) + ); + assert_eq!( + unwrap_pattern(parse_into_kind(r#" foo:"bar" "#)?), + ("foo", ExpressionKind::String("bar".to_owned())) + ); + assert_eq!( + unwrap_pattern(parse_into_kind(r#" foo:"" "#)?), + ("foo", ExpressionKind::String("".to_owned())) + ); + assert_eq!( + unwrap_pattern(parse_into_kind(r#" foo:'\' "#)?), + ("foo", ExpressionKind::String(r"\".to_owned())) + ); + assert_eq!( + parse_into_kind(r#" foo: "#), + Err(FilesetParseErrorKind::SyntaxError) + ); + + // Whitespace isn't allowed in between + assert_eq!( + parse_into_kind(r#" foo: "" "#), + Err(FilesetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_into_kind(r#" foo :"" "#), + Err(FilesetParseErrorKind::SyntaxError) + ); + // Whitespace is allowed in parenthesized value expression + assert_eq!( + parse_normalized("foo:( 'bar' )"), + parse_normalized("foo:'bar'") + ); + + // Functions are allowed + assert_eq!(parse_normalized("x:f(y)"), parse_normalized("x:(f(y))")); + // Logical operators have lower binding strength + assert_eq!(parse_normalized("x:y&z"), parse_normalized("(x:y)&(z)")); + assert_matches!( + parse_into_kind("x:~y"), // (x:) ~ (y) + Err(FilesetParseErrorKind::SyntaxError) + ); + + // Pattern prefix is like (type)x cast, so is evaluated from right + assert_eq!(parse_normalized("x:y:z"), parse_normalized("x:(y:z)")); + Ok(()) + } + + #[test] + fn test_parse_operator() -> TestResult { + assert_matches!( + parse_into_kind("~x"), + Ok(ExpressionKind::Unary(UnaryOp::Negate, _)) + ); + assert_matches!( + parse_into_kind("x|y"), + Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 2 + ); + assert_matches!( + parse_into_kind("x|y|z"), + Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 3 + ); + assert_matches!( + parse_into_kind("x&y"), + Ok(ExpressionKind::Binary(BinaryOp::Intersection, _, _)) + ); + assert_matches!( + parse_into_kind("x~y"), + Ok(ExpressionKind::Binary(BinaryOp::Difference, _, _)) + ); + + // Set operator associativity/precedence + assert_eq!(parse_normalized("~x|y"), parse_normalized("(~x)|y")); + assert_eq!(parse_normalized("x&~y"), parse_normalized("x&(~y)")); + assert_eq!(parse_normalized("x~~y"), parse_normalized("x~(~y)")); + assert_eq!(parse_normalized("x~~~y"), parse_normalized("x~(~(~y))")); + assert_eq!(parse_normalized("x|y|z"), parse_normalized("(x|y)|z")); + assert_eq!(parse_normalized("x&y|z"), parse_normalized("(x&y)|z")); + assert_eq!(parse_normalized("x|y&z"), parse_normalized("x|(y&z)")); + assert_eq!(parse_normalized("x|y~z"), parse_normalized("x|(y~z)")); + assert_eq!(parse_normalized("~x:y"), parse_normalized("~(x:y)")); + assert_eq!(parse_normalized("x|y:z"), parse_normalized("x|(y:z)")); + + // Expression span + assert_eq!(parse_program(" ~ x ")?.span.as_str(), "~ x"); + assert_eq!(parse_program(" x |y ")?.span.as_str(), "x |y"); + assert_eq!(parse_program(" (x) ")?.span.as_str(), "(x)"); + assert_eq!(parse_program("~( x|y) ")?.span.as_str(), "~( x|y)"); + Ok(()) + } + + #[test] + fn test_parse_function_call() -> TestResult { + fn unwrap_function_call(node: ExpressionNode<'_>) -> Box> { + match node.kind { + ExpressionKind::FunctionCall(function) => function, + _ => panic!("unexpected expression: {node:?}"), + } + } + + assert_matches!( + parse_into_kind("foo()"), + Ok(ExpressionKind::FunctionCall(_)) + ); + + // Trailing comma isn't allowed for empty argument + assert!(parse_into_kind("foo(,)").is_err()); + + // Trailing comma is allowed for the last argument + assert_eq!(parse_normalized("foo(a,)"), parse_normalized("foo(a)")); + assert_eq!(parse_normalized("foo(a , )"), parse_normalized("foo(a)")); + assert!(parse_into_kind("foo(,a)").is_err()); + assert!(parse_into_kind("foo(a,,)").is_err()); + assert!(parse_into_kind("foo(a , , )").is_err()); + assert_eq!(parse_normalized("foo(a,b,)"), parse_normalized("foo(a,b)")); + assert!(parse_into_kind("foo(a,,b)").is_err()); + + // Expression span + let function = unwrap_function_call(parse_program("foo( a, (b) , ~(c) )")?); + assert_eq!(function.name_span.as_str(), "foo"); + assert_eq!(function.args_span.as_str(), "a, (b) , ~(c)"); + assert_eq!(function.args[0].span.as_str(), "a"); + assert_eq!(function.args[1].span.as_str(), "(b)"); + assert_eq!(function.args[2].span.as_str(), "~(c)"); + Ok(()) + } + + #[test] + fn test_parse_bare_string() -> TestResult { + fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { + match kind { + ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), + _ => panic!("unexpected expression: {kind:?}"), + } + } + + // Valid expression should be parsed as such + assert_eq!( + parse_maybe_bare_into_kind(" valid "), + Ok(ExpressionKind::Identifier("valid")) + ); + assert_eq!( + parse_maybe_bare_normalized("f(x)&y"), + parse_normalized("f(x)&y") + ); + assert_eq!( + unwrap_pattern(parse_maybe_bare_into_kind("foo:bar")?), + ("foo", ExpressionKind::Identifier("bar")) + ); + + // Bare string + assert_eq!( + parse_maybe_bare_into_kind("Foo Bar.txt"), + Ok(ExpressionKind::String("Foo Bar.txt".to_owned())) + ); + assert_eq!( + parse_maybe_bare_into_kind(r#"Windows\Path with space"#), + Ok(ExpressionKind::String( + r#"Windows\Path with space"#.to_owned() + )) + ); + assert_eq!( + parse_maybe_bare_into_kind("柔 術 . j j"), + Ok(ExpressionKind::String("柔 術 . j j".to_owned())) + ); + assert_eq!( + parse_maybe_bare_into_kind("Unicode emoji 💩"), + Ok(ExpressionKind::String("Unicode emoji 💩".to_owned())) + ); + assert_eq!( + parse_maybe_bare_into_kind("looks like & expression"), + Err(FilesetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_maybe_bare_into_kind("unbalanced_parens("), + Err(FilesetParseErrorKind::SyntaxError) + ); + + // Bare string pattern + assert_eq!( + unwrap_pattern(parse_maybe_bare_into_kind("foo: bar baz")?), + ("foo", ExpressionKind::String(" bar baz".to_owned())) + ); + assert_eq!( + unwrap_pattern(parse_maybe_bare_into_kind("foo:glob * [chars]?")?), + ("foo", ExpressionKind::String("glob * [chars]?".to_owned())) + ); + assert_eq!( + parse_maybe_bare_into_kind("foo: bar:baz"), + Err(FilesetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_maybe_bare_into_kind("foo:"), + Err(FilesetParseErrorKind::SyntaxError) + ); + assert_eq!( + parse_maybe_bare_into_kind(r#"foo:"unclosed quote"#), + Err(FilesetParseErrorKind::SyntaxError) + ); + + // Surrounding spaces are simply preserved. They could be trimmed, but + // space is valid bare_string character. + assert_eq!( + parse_maybe_bare_into_kind(" No trim "), + Ok(ExpressionKind::String(" No trim ".to_owned())) + ); + Ok(()) + } + + #[test] + fn test_parse_error() { + insta::assert_snapshot!(parse_program("foo|").unwrap_err().to_string(), @" + --> 1:5 + | + 1 | foo| + | ^--- + | + = expected `~` or + "); + } + + #[test] + fn test_parse_alias_symbol_decl() -> TestResult { + let mut aliases_map = FilesetAliasesMap::new(); + aliases_map.insert("sym", "symbol", None)?; + assert_eq!(aliases_map.symbol_names().count(), 1); + let (id, defn, _doc) = aliases_map.get_symbol("sym").unwrap(); + assert_eq!(id, AliasId::Symbol("sym")); + assert_eq!(defn, "symbol"); + + // Non-ASCII character isn't allowed in alias symbol. This rule can be + // relaxed if needed. + assert!(aliases_map.insert("柔術", "none()", None).is_err()); + Ok(()) + } + + #[test] + fn test_parse_alias_pattern_decl() -> TestResult { + let mut aliases_map = FilesetAliasesMap::new(); + assert!(aliases_map.insert("pat:", "bad_pattern", None).is_err()); + aliases_map.insert("pat:a", "pattern_a", None)?; + aliases_map.insert("pat:b", "pattern_b", None)?; + assert_eq!(aliases_map.pattern_names().count(), 1); + let (id, param, defn, _doc) = aliases_map.get_pattern("pat").unwrap(); + assert_eq!(id, AliasId::Pattern("pat", "b")); + assert_eq!(param, "b"); + assert_eq!(defn, "pattern_b"); + + // Non-ASCII character isn't allowed. This rule can be relaxed if + // needed. + assert!(aliases_map.insert("柔術:x", "none()", None).is_err()); + assert!(aliases_map.insert("x:柔術", "none()", None).is_err()); + Ok(()) + } + + #[test] + fn test_parse_alias_func_decl() -> TestResult { + let mut aliases_map = FilesetAliasesMap::new(); + assert!(aliases_map.insert("5func()", "bad_function", None).is_err()); + aliases_map.insert("func()", "function_0", None)?; + aliases_map.insert("func(a)", "function_1a", None)?; + aliases_map.insert("func(b)", "function_1b", None)?; + aliases_map.insert("func(a, b)", "function_2", None)?; + assert_eq!(aliases_map.function_names().count(), 1); + + let (id, params, defn, _doc) = aliases_map.get_function("func", 0).unwrap(); + assert_eq!(id, AliasId::Function("func", &[])); + assert!(params.is_empty()); + assert_eq!(defn, "function_0"); + + let (id, params, defn, _doc) = aliases_map.get_function("func", 1).unwrap(); + assert_eq!(id, AliasId::Function("func", &["b".to_owned()])); + assert_eq!(params, ["b"]); + assert_eq!(defn, "function_1b"); + + let (id, params, defn, _doc) = aliases_map.get_function("func", 2).unwrap(); + assert_eq!( + id, + AliasId::Function("func", &["a".to_owned(), "b".to_owned()]) + ); + assert_eq!(params, ["a", "b"]); + assert_eq!(defn, "function_2"); + + assert!(aliases_map.get_function("func", 3).is_none()); + Ok(()) + } + + #[test] + fn test_parse_alias_formal_parameter() { + let mut aliases_map = FilesetAliasesMap::new(); + // Formal parameter 'a' can't be redefined + assert_eq!( + aliases_map.insert("f(a, a)", "bad", None).unwrap_err().kind, + FilesetParseErrorKind::RedefinedFunctionParameter + ); + // Trailing comma isn't allowed for empty parameter + assert!(aliases_map.insert("f(,)", "bad", None).is_err()); + // Trailing comma is allowed for the last parameter + assert!(aliases_map.insert("g(a,)", "bad", None).is_ok()); + assert!(aliases_map.insert("h(a , )", "bad", None).is_ok()); + assert!(aliases_map.insert("i(,a)", "bad", None).is_err()); + assert!(aliases_map.insert("j(a,,)", "bad", None).is_err()); + assert!(aliases_map.insert("k(a , , )", "bad", None).is_err()); + assert!(aliases_map.insert("l(a,b,)", "bad", None).is_ok()); + assert!(aliases_map.insert("m(a,,b)", "bad", None).is_err()); + } + + #[test] + fn test_expand_symbol_alias() { + assert_eq!( + with_aliases([("AB", "a&b")]).parse_normalized("AB|c"), + parse_normalized("(a&b)|c") + ); + assert_eq!( + with_aliases([("AB", "a|b")]).parse_normalized("AB~f(AB)"), + parse_normalized("(a|b)~f(a|b)") + ); + + // Not string substitution 'a&b|c', but tree substitution. + assert_eq!( + with_aliases([("BC", "b|c")]).parse_normalized("a&BC"), + parse_normalized("a&(b|c)") + ); + + // String literal should not be substituted with alias. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized(r#"A|"A"|'A'"#), + parse_normalized("a|'A'|'A'") + ); + + // Kind of string pattern should not be substituted, which is similar to + // function name. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("A:b"), + parse_normalized("A:b") + ); + + // Value of string pattern can be substituted if it's an identifier. + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("p:A"), + parse_normalized("p:a") + ); + assert_eq!( + with_aliases([("A", "a")]).parse_normalized("p:'A'"), + parse_normalized("p:'A'") + ); + + // Multi-level substitution. + assert_eq!( + with_aliases([("A", "BC"), ("BC", "b|C"), ("C", "c")]).parse_normalized("A"), + parse_normalized("b|c") + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + with_aliases([("A", "A")]).parse("A").unwrap_err().kind, + FilesetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + assert_eq!( + with_aliases([("A", "B"), ("B", "b|C"), ("C", "c|B")]) + .parse("A") + .unwrap_err() + .kind, + FilesetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + + // Error in alias definition. + assert_eq!( + with_aliases([("A", "a(")]).parse("A").unwrap_err().kind, + FilesetParseErrorKind::InAliasExpansion("A".to_owned()) + ); + } + + #[test] + fn test_expand_pattern_alias() { + assert_eq!( + with_aliases([("P:x", "x")]).parse_normalized("P:a"), + parse_normalized("a") + ); + + // Argument should be resolved in the current scope. + assert_eq!( + with_aliases([("P:x", "x|a")]).parse_normalized("P:x"), + parse_normalized("x|a") + ); + // P:a -> (Q:a)&y -> (x|a)&y + assert_eq!( + with_aliases([("P:x", "(Q:x)&y"), ("Q:y", "x|y")]).parse_normalized("P:a"), + parse_normalized("(x|a)&y") + ); + + // Pattern parameter should precede the symbol alias. + assert_eq!( + with_aliases([("P:X", "X"), ("X", "x")]).parse_normalized("(P:a)|X"), + parse_normalized("a|x") + ); + + // Pattern parameter shouldn't be expanded in symbol alias. + assert_eq!( + with_aliases([("P:x", "x|A"), ("A", "x")]).parse_normalized("P:a"), + parse_normalized("a|x") + ); + + // String literal should not be substituted with pattern parameter. + assert_eq!( + with_aliases([("P:x", "x|'x'")]).parse_normalized("P:a"), + parse_normalized("a|'x'") + ); + + // Pattern and symbol aliases reside in separate namespaces. + assert_eq!( + with_aliases([("A:x", "A"), ("A", "a")]).parse_normalized("A:x"), + parse_normalized("a") + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + with_aliases([("P:x", "Q:x"), ("Q:x", "R:x"), ("R:x", "P:x")]) + .parse("P:a") + .unwrap_err() + .kind, + FilesetParseErrorKind::InAliasExpansion("P:x".to_owned()) + ); + } + + #[test] + fn test_expand_function_alias() { + assert_eq!( + with_aliases([("F( )", "a")]).parse_normalized("F()"), + parse_normalized("a") + ); + assert_eq!( + with_aliases([("F( x )", "x")]).parse_normalized("F(a)"), + parse_normalized("a") + ); + assert_eq!( + with_aliases([("F( x, y )", "x|y")]).parse_normalized("F(a, b)"), + parse_normalized("a|b") + ); + + // Not recursion because functions are overloaded by arity. + assert_eq!( + with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "x|y")]).parse_normalized("F(a)"), + parse_normalized("a|b") + ); + + // Arguments should be resolved in the current scope. + assert_eq!( + with_aliases([("F(x,y)", "x|y")]).parse_normalized("F(a~y,b~x)"), + parse_normalized("(a~y)|(b~x)") + ); + // F(a) -> G(a)&y -> (x|a)&y + assert_eq!( + with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(a)"), + parse_normalized("(x|a)&y") + ); + // F(G(a)) -> F(x|a) -> G(x|a)&y -> (x|(x|a))&y + assert_eq!( + with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(G(a))"), + parse_normalized("(x|(x|a))&y") + ); + + // Function parameter should precede the symbol alias. + assert_eq!( + with_aliases([("F(X)", "X"), ("X", "x")]).parse_normalized("F(a)|X"), + parse_normalized("a|x") + ); + + // Function parameter shouldn't be expanded in symbol alias. + assert_eq!( + with_aliases([("F(x)", "x|A"), ("A", "x")]).parse_normalized("F(a)"), + parse_normalized("a|x") + ); + + // String literal should not be substituted with function parameter. + assert_eq!( + with_aliases([("F(x)", "x|'x'")]).parse_normalized("F(a)"), + parse_normalized("a|'x'") + ); + + // Function and symbol aliases reside in separate namespaces. + assert_eq!( + with_aliases([("A()", "A"), ("A", "a")]).parse_normalized("A()"), + parse_normalized("a") + ); + + // Invalid number of arguments. + assert_eq!( + with_aliases([("F()", "x")]).parse("F(a)").unwrap_err().kind, + FilesetParseErrorKind::InvalidArguments { + name: "F".to_owned(), + message: "Expected 0 arguments".to_owned() + } + ); + assert_eq!( + with_aliases([("F(x)", "x")]).parse("F()").unwrap_err().kind, + FilesetParseErrorKind::InvalidArguments { + name: "F".to_owned(), + message: "Expected 1 arguments".to_owned() + } + ); + assert_eq!( + with_aliases([("F(x,y)", "x|y")]) + .parse("F(a,b,c)") + .unwrap_err() + .kind, + FilesetParseErrorKind::InvalidArguments { + name: "F".to_owned(), + message: "Expected 2 arguments".to_owned() + } + ); + assert_eq!( + with_aliases([("F(x)", "x"), ("F(x,y)", "x|y")]) + .parse("F()") + .unwrap_err() + .kind, + FilesetParseErrorKind::InvalidArguments { + name: "F".to_owned(), + message: "Expected 1 to 2 arguments".to_owned() + } + ); + assert_eq!( + with_aliases([("F()", "x"), ("F(x,y)", "x|y")]) + .parse("F(a)") + .unwrap_err() + .kind, + FilesetParseErrorKind::InvalidArguments { + name: "F".to_owned(), + message: "Expected 0, 2 arguments".to_owned() + } + ); + + // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. + assert_eq!( + with_aliases([("F(x)", "G(x)"), ("G(x)", "H(x)"), ("H(x)", "F(x)")]) + .parse("F(a)") + .unwrap_err() + .kind, + FilesetParseErrorKind::InAliasExpansion("F(x)".to_owned()) + ); + assert_eq!( + with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "F(x|y)")]) + .parse("F(a)") + .unwrap_err() + .kind, + FilesetParseErrorKind::InAliasExpansion("F(x)".to_owned()) + ); + } +} diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index 952055f1aba..f1852278f5f 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -32,6 +32,7 @@ pub mod content_hash; pub mod backend; pub mod dsl_util; pub mod file_util; +pub mod fileset_parser; pub mod hex_util; pub mod matchers; pub mod object_id; diff --git a/lib/src/fileset_parser.rs b/lib/src/fileset_parser.rs index d308df1f398..a1ba1f3784c 100644 --- a/lib/src/fileset_parser.rs +++ b/lib/src/fileset_parser.rs @@ -14,1351 +14,25 @@ //! Parser for the fileset language. -use std::error; -use std::sync::LazyLock; - -use itertools::Itertools as _; -use pest::Parser as _; -use pest::iterators::Pair; -use pest::pratt_parser::Assoc; -use pest::pratt_parser::Op; -use pest::pratt_parser::PrattParser; -use pest_derive::Parser; -use thiserror::Error; - -use crate::dsl_util; -use crate::dsl_util::AliasDeclaration; -use crate::dsl_util::AliasDeclarationParser; -use crate::dsl_util::AliasDefinitionParser; -use crate::dsl_util::AliasExpandError; -use crate::dsl_util::AliasExpandableExpression; -use crate::dsl_util::AliasId; -use crate::dsl_util::AliasesMap; -use crate::dsl_util::Diagnostics; -use crate::dsl_util::ExpressionFolder; -use crate::dsl_util::FoldableExpression; -use crate::dsl_util::InvalidArguments; -use crate::dsl_util::StringLiteralParser; - -#[derive(Parser)] -#[grammar = "fileset.pest"] -struct FilesetParser; - -const STRING_LITERAL_PARSER: StringLiteralParser = StringLiteralParser { - content_rule: Rule::string_content, - escape_rule: Rule::string_escape, -}; - -impl Rule { - fn to_symbol(self) -> Option<&'static str> { - match self { - Self::EOI => None, - Self::whitespace => None, - Self::identifier => None, - Self::strict_identifier_part => None, - Self::strict_identifier => None, - Self::bare_string => None, - Self::string_escape => None, - Self::string_content_char => None, - Self::string_content => None, - Self::string_literal => None, - Self::raw_string_content => None, - Self::raw_string_literal => None, - Self::pattern_kind_op => Some(":"), - Self::negate_op => Some("~"), - Self::union_op => Some("|"), - Self::intersection_op => Some("&"), - Self::difference_op => Some("~"), - Self::prefix_ops => None, - Self::infix_ops => None, - Self::function => None, - Self::function_name => None, - Self::function_arguments => None, - Self::formal_parameters => None, - Self::pattern => None, - Self::bare_string_pattern => None, - Self::primary => None, - Self::expression => None, - Self::program => None, - Self::program_or_bare_string => None, - Self::function_alias_declaration => None, - Self::pattern_alias_declaration => None, - Self::alias_declaration => None, - } - } -} - -/// Manages diagnostic messages emitted during fileset parsing and name -/// resolution. -pub type FilesetDiagnostics = Diagnostics; - -/// Result of fileset parsing and name resolution. -pub type FilesetParseResult = Result; - -/// Error occurred during fileset parsing and name resolution. -#[derive(Debug, Error)] -#[error("{pest_error}")] -pub struct FilesetParseError { - kind: FilesetParseErrorKind, - pest_error: Box>, - source: Option>, -} - -/// Categories of fileset parsing and name resolution error. -#[expect(missing_docs)] -#[derive(Clone, Debug, Eq, Error, PartialEq)] -pub enum FilesetParseErrorKind { - #[error("Syntax error")] - SyntaxError, - #[error("Function `{name}` doesn't exist")] - NoSuchFunction { - name: String, - candidates: Vec, - }, - #[error("Function `{name}`: {message}")] - InvalidArguments { name: String, message: String }, - #[error("Redefinition of function parameter")] - RedefinedFunctionParameter, - #[error("{0}")] - Expression(String), - #[error("In alias `{0}`")] - InAliasExpansion(String), - #[error("In function parameter `{0}`")] - InParameterExpansion(String), - #[error("Alias `{0}` expanded recursively")] - RecursiveAlias(String), -} - -impl FilesetParseError { - pub(super) fn new(kind: FilesetParseErrorKind, span: pest::Span<'_>) -> Self { - let message = kind.to_string(); - let pest_error = Box::new(pest::error::Error::new_from_span( - pest::error::ErrorVariant::CustomError { message }, - span, - )); - Self { - kind, - pest_error, - source: None, - } - } - - pub(super) fn with_source( - mut self, - source: impl Into>, - ) -> Self { - self.source = Some(source.into()); - self - } - - /// Some other expression error. - pub(super) fn expression(message: impl Into, span: pest::Span<'_>) -> Self { - Self::new(FilesetParseErrorKind::Expression(message.into()), span) - } - - /// Category of the underlying error. - pub fn kind(&self) -> &FilesetParseErrorKind { - &self.kind - } -} - -impl AliasExpandError for FilesetParseError { - fn invalid_arguments(err: InvalidArguments<'_>) -> Self { - err.into() - } - - fn recursive_expansion(id: AliasId<'_>, span: pest::Span<'_>) -> Self { - Self::new(FilesetParseErrorKind::RecursiveAlias(id.to_string()), span) - } - - fn within_alias_expansion(self, id: AliasId<'_>, span: pest::Span<'_>) -> Self { - let kind = match id { - AliasId::Symbol(_) | AliasId::Pattern(..) | AliasId::Function(..) => { - FilesetParseErrorKind::InAliasExpansion(id.to_string()) - } - AliasId::Parameter(_) => FilesetParseErrorKind::InParameterExpansion(id.to_string()), - }; - Self::new(kind, span).with_source(self) - } -} - -impl From> for FilesetParseError { - fn from(err: pest::error::Error) -> Self { - Self { - kind: FilesetParseErrorKind::SyntaxError, - pest_error: Box::new(rename_rules_in_pest_error(err)), - source: None, - } - } -} - -impl From> for FilesetParseError { - fn from(err: InvalidArguments<'_>) -> Self { - let kind = FilesetParseErrorKind::InvalidArguments { - name: err.name.to_owned(), - message: err.message, - }; - Self::new(kind, err.span) - } -} - -fn rename_rules_in_pest_error(err: pest::error::Error) -> pest::error::Error { - err.renamed_rules(|rule| { - rule.to_symbol() - .map(|sym| format!("`{sym}`")) - .unwrap_or_else(|| format!("<{rule:?}>")) - }) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ExpressionKind<'i> { - Identifier(&'i str), - String(String), - /// `:` where `` is usually `Identifier` or `String`. - Pattern(Box>), - Unary(UnaryOp, Box>), - Binary(BinaryOp, Box>, Box>), - /// `x | y | ..` - UnionAll(Vec>), - FunctionCall(Box>), - /// Identity node to preserve the span in the source text. - AliasExpanded(AliasId<'i>, Box>), -} - -impl<'i> FoldableExpression<'i> for ExpressionKind<'i> { - fn fold(self, folder: &mut F, span: pest::Span<'i>) -> Result - where - F: ExpressionFolder<'i, Self> + ?Sized, - { - match self { - Self::Identifier(name) => folder.fold_identifier(name, span), - Self::String(_) => Ok(self), - Self::Pattern(pattern) => folder.fold_pattern(pattern, span), - Self::Unary(op, arg) => { - let arg = Box::new(folder.fold_expression(*arg)?); - Ok(Self::Unary(op, arg)) - } - Self::Binary(op, lhs, rhs) => { - let lhs = Box::new(folder.fold_expression(*lhs)?); - let rhs = Box::new(folder.fold_expression(*rhs)?); - Ok(Self::Binary(op, lhs, rhs)) - } - Self::UnionAll(nodes) => { - let nodes = dsl_util::fold_expression_nodes(folder, nodes)?; - Ok(Self::UnionAll(nodes)) - } - Self::FunctionCall(function) => folder.fold_function_call(function, span), - Self::AliasExpanded(id, subst) => { - let subst = Box::new(folder.fold_expression(*subst)?); - Ok(Self::AliasExpanded(id, subst)) - } - } - } -} - -impl<'i> AliasExpandableExpression<'i> for ExpressionKind<'i> { - fn identifier(name: &'i str) -> Self { - Self::Identifier(name) - } - - fn pattern(pattern: Box>) -> Self { - Self::Pattern(pattern) - } - - fn function_call(function: Box>) -> Self { - Self::FunctionCall(function) - } - - fn alias_expanded(id: AliasId<'i>, subst: Box>) -> Self { - Self::AliasExpanded(id, subst) - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum UnaryOp { - /// `~` - Negate, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum BinaryOp { - /// `&` - Intersection, - /// `~` - Difference, -} - -pub type ExpressionNode<'i> = dsl_util::ExpressionNode<'i, ExpressionKind<'i>>; -pub type FunctionCallNode<'i> = dsl_util::FunctionCallNode<'i, ExpressionKind<'i>>; -pub type PatternNode<'i> = dsl_util::PatternNode<'i, ExpressionKind<'i>>; - -fn union_nodes<'i>(lhs: ExpressionNode<'i>, rhs: ExpressionNode<'i>) -> ExpressionNode<'i> { - let span = lhs.span.start_pos().span(&rhs.span.end_pos()); - let expr = match lhs.kind { - // Flatten "x | y | z" to save recursion stack. Machine-generated query - // might have long chain of unions. - ExpressionKind::UnionAll(mut nodes) => { - nodes.push(rhs); - ExpressionKind::UnionAll(nodes) - } - _ => ExpressionKind::UnionAll(vec![lhs, rhs]), - }; - ExpressionNode::new(expr, span) -} - -fn parse_function_call_node(pair: Pair) -> FilesetParseResult { - assert_eq!(pair.as_rule(), Rule::function); - let [name_pair, args_pair] = pair.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), Rule::function_name); - assert_eq!(args_pair.as_rule(), Rule::function_arguments); - let name_span = name_pair.as_span(); - let args_span = args_pair.as_span(); - let name = name_pair.as_str(); - let args = args_pair - .into_inner() - .map(parse_expression_node) - .try_collect()?; - Ok(FunctionCallNode { - name, - name_span, - args, - keyword_args: vec![], // unsupported - args_span, - }) -} - -fn parse_as_string_literal(pair: Pair) -> String { - match pair.as_rule() { - Rule::identifier => pair.as_str().to_owned(), - Rule::string_literal => STRING_LITERAL_PARSER.parse(pair.into_inner()), - Rule::raw_string_literal => { - let [content] = pair.into_inner().collect_array().unwrap(); - assert_eq!(content.as_rule(), Rule::raw_string_content); - content.as_str().to_owned() - } - r => panic!("unexpected string literal rule: {r:?}"), - } -} - -fn parse_primary_node(pair: Pair) -> FilesetParseResult { - assert_eq!(pair.as_rule(), Rule::primary); - let span = pair.as_span(); - let first = pair.into_inner().next().unwrap(); - let expr = match first.as_rule() { - // Ignore inner span to preserve parenthesized expression as such. - Rule::expression => parse_expression_node(first)?.kind, - Rule::function => { - let function = Box::new(parse_function_call_node(first)?); - ExpressionKind::FunctionCall(function) - } - Rule::pattern => { - let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); - assert_eq!(lhs.as_rule(), Rule::strict_identifier); - assert_eq!(op.as_rule(), Rule::pattern_kind_op); - let pattern = Box::new(PatternNode { - name: lhs.as_str(), - name_span: lhs.as_span(), - value: parse_primary_node(rhs)?, - }); - ExpressionKind::Pattern(pattern) - } - Rule::identifier => ExpressionKind::Identifier(first.as_str()), - Rule::string_literal | Rule::raw_string_literal => { - ExpressionKind::String(parse_as_string_literal(first)) - } - r => panic!("unexpected primary rule: {r:?}"), - }; - Ok(ExpressionNode::new(expr, span)) -} - -fn parse_expression_node(pair: Pair) -> FilesetParseResult { - assert_eq!(pair.as_rule(), Rule::expression); - static PRATT: LazyLock> = LazyLock::new(|| { - PrattParser::new() - .op(Op::infix(Rule::union_op, Assoc::Left)) - .op(Op::infix(Rule::intersection_op, Assoc::Left) - | Op::infix(Rule::difference_op, Assoc::Left)) - .op(Op::prefix(Rule::negate_op)) - }); - PRATT - .map_primary(parse_primary_node) - .map_prefix(|op, rhs| { - let op_kind = match op.as_rule() { - Rule::negate_op => UnaryOp::Negate, - r => panic!("unexpected prefix operator rule {r:?}"), - }; - let rhs = Box::new(rhs?); - let span = op.as_span().start_pos().span(&rhs.span.end_pos()); - let expr = ExpressionKind::Unary(op_kind, rhs); - Ok(ExpressionNode::new(expr, span)) - }) - .map_infix(|lhs, op, rhs| { - let op_kind = match op.as_rule() { - Rule::union_op => return Ok(union_nodes(lhs?, rhs?)), - Rule::intersection_op => BinaryOp::Intersection, - Rule::difference_op => BinaryOp::Difference, - r => panic!("unexpected infix operator rule {r:?}"), - }; - let lhs = Box::new(lhs?); - let rhs = Box::new(rhs?); - let span = lhs.span.start_pos().span(&rhs.span.end_pos()); - let expr = ExpressionKind::Binary(op_kind, lhs, rhs); - Ok(ExpressionNode::new(expr, span)) - }) - .parse(pair.into_inner()) -} - -/// Parses text into expression tree. No name resolution is made at this stage. -pub fn parse_program(text: &str) -> FilesetParseResult> { - let mut pairs = FilesetParser::parse(Rule::program, text)?; - let first = pairs.next().unwrap(); - parse_expression_node(first) -} - -/// Parses text into expression tree with bare string fallback. No name -/// resolution is made at this stage. -/// -/// If the text can't be parsed as a fileset expression, and if it doesn't -/// contain any operator-like characters, it will be parsed as a file path. -pub fn parse_program_or_bare_string(text: &str) -> FilesetParseResult> { - let mut pairs = FilesetParser::parse(Rule::program_or_bare_string, text)?; - let first = pairs.next().unwrap(); - let span = first.as_span(); - let expr = match first.as_rule() { - Rule::expression => return parse_expression_node(first), - Rule::bare_string_pattern => { - let [lhs, op, rhs] = first.into_inner().collect_array().unwrap(); - assert_eq!(lhs.as_rule(), Rule::strict_identifier); - assert_eq!(op.as_rule(), Rule::pattern_kind_op); - assert_eq!(rhs.as_rule(), Rule::bare_string); - let name_span = lhs.as_span(); - let value_span = rhs.as_span(); - let name = lhs.as_str(); - let value_expr = ExpressionKind::String(rhs.as_str().to_owned()); - let pattern = Box::new(PatternNode { - name, - name_span, - value: ExpressionNode::new(value_expr, value_span), - }); - ExpressionKind::Pattern(pattern) - } - Rule::bare_string => ExpressionKind::String(first.as_str().to_owned()), - r => panic!("unexpected program or bare string rule: {r:?}"), - }; - Ok(ExpressionNode::new(expr, span)) -} - -/// Map of fileset aliases. -pub type FilesetAliasesMap = AliasesMap; - -#[derive(Clone, Debug, Default)] -pub struct FilesetAliasParser; - -impl AliasDeclarationParser for FilesetAliasParser { - type Error = FilesetParseError; - - fn parse_declaration(&self, source: &str) -> Result { - let mut pairs = FilesetParser::parse(Rule::alias_declaration, source)?; - let first = pairs.next().unwrap(); - match first.as_rule() { - Rule::strict_identifier => Ok(AliasDeclaration::Symbol(first.as_str().to_owned())), - Rule::pattern_alias_declaration => { - let [name_pair, op, param_pair] = first.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), Rule::strict_identifier); - assert_eq!(op.as_rule(), Rule::pattern_kind_op); - assert_eq!(param_pair.as_rule(), Rule::strict_identifier); - let name = name_pair.as_str().to_owned(); - let param = param_pair.as_str().to_owned(); - Ok(AliasDeclaration::Pattern(name, param)) - } - Rule::function_alias_declaration => { - let [name_pair, params_pair] = first.into_inner().collect_array().unwrap(); - assert_eq!(name_pair.as_rule(), Rule::function_name); - assert_eq!(params_pair.as_rule(), Rule::formal_parameters); - let name = name_pair.as_str().to_owned(); - let params_span = params_pair.as_span(); - let params = params_pair - .into_inner() - .map(|pair| match pair.as_rule() { - Rule::strict_identifier => pair.as_str().to_owned(), - r => panic!("unexpected formal parameter rule {r:?}"), - }) - .collect_vec(); - if params.iter().all_unique() { - Ok(AliasDeclaration::Function(name, params)) - } else { - Err(FilesetParseError::new( - FilesetParseErrorKind::RedefinedFunctionParameter, - params_span, - )) - } - } - r => panic!("unexpected alias declaration rule {r:?}"), - } - } -} - -impl AliasDefinitionParser for FilesetAliasParser { - type Output<'i> = ExpressionKind<'i>; - type Error = FilesetParseError; - - fn parse_definition<'i>(&self, source: &'i str) -> Result, Self::Error> { - parse_program(source) - } -} - -pub fn expand_aliases<'i>( - node: ExpressionNode<'i>, - aliases_map: &'i FilesetAliasesMap, -) -> FilesetParseResult> { - dsl_util::expand_aliases(node, aliases_map) -} - -pub(super) fn expect_string_literal<'a>( - type_name: &str, - node: &'a ExpressionNode<'_>, -) -> FilesetParseResult<&'a str> { - catch_aliases_no_diagnostics(node, |node| match &node.kind { - ExpressionKind::Identifier(name) => Ok(*name), - ExpressionKind::String(name) => Ok(name), - _ => Err(FilesetParseError::expression( - format!("Expected {type_name}"), - node.span, - )), - }) -} - -/// Applies the given function to the innermost `node` by unwrapping alias -/// expansion nodes. Appends alias expansion stack to error and diagnostics. -pub(super) fn catch_aliases<'a, 'i, T>( - diagnostics: &mut FilesetDiagnostics, - node: &'a ExpressionNode<'i>, - f: impl FnOnce(&mut FilesetDiagnostics, &'a ExpressionNode<'i>) -> Result, -) -> Result { - let (node, stack) = skip_aliases(node); - if stack.is_empty() { - f(diagnostics, node) - } else { - let mut inner_diagnostics = FilesetDiagnostics::new(); - let result = f(&mut inner_diagnostics, node); - diagnostics.extend_with(inner_diagnostics, |diag| attach_aliases_err(diag, &stack)); - result.map_err(|err| attach_aliases_err(err, &stack)) - } -} - -fn catch_aliases_no_diagnostics<'a, 'i, T>( - node: &'a ExpressionNode<'i>, - f: impl FnOnce(&'a ExpressionNode<'i>) -> Result, -) -> Result { - let (node, stack) = skip_aliases(node); - f(node).map_err(|err| attach_aliases_err(err, &stack)) -} - -fn skip_aliases<'a, 'i>( - mut node: &'a ExpressionNode<'i>, -) -> (&'a ExpressionNode<'i>, Vec<(AliasId<'i>, pest::Span<'i>)>) { - let mut stack = Vec::new(); - while let ExpressionKind::AliasExpanded(id, subst) = &node.kind { - stack.push((*id, node.span)); - node = subst; - } - (node, stack) -} - -fn attach_aliases_err( - err: FilesetParseError, - stack: &[(AliasId<'_>, pest::Span<'_>)], -) -> FilesetParseError { - stack - .iter() - .rfold(err, |err, &(id, span)| err.within_alias_expansion(id, span)) -} - -#[cfg(test)] -mod tests { - use assert_matches::assert_matches; - - use super::*; - use crate::dsl_util::KeywordArgument; - use crate::tests::TestResult; - - #[derive(Debug)] - struct WithFilesetAliasesMap { - aliases_map: FilesetAliasesMap, - } - - impl WithFilesetAliasesMap { - fn parse<'i>(&'i self, text: &'i str) -> FilesetParseResult> { - let node = parse_program(text)?; - expand_aliases(node, &self.aliases_map) - } - - fn parse_normalized<'i>(&'i self, text: &'i str) -> ExpressionNode<'i> { - normalize_tree(self.parse(text).unwrap()) - } - } - - fn with_aliases( - aliases: impl IntoIterator, impl Into)>, - ) -> WithFilesetAliasesMap { - let mut aliases_map = FilesetAliasesMap::new(); - for (decl, defn) in aliases { - aliases_map.insert(decl, defn, None).unwrap(); - } - WithFilesetAliasesMap { aliases_map } - } - - fn parse_into_kind(text: &str) -> Result, FilesetParseErrorKind> { - parse_program(text) - .map(|node| node.kind) - .map_err(|err| err.kind) - } - - fn parse_maybe_bare_into_kind(text: &str) -> Result, FilesetParseErrorKind> { - parse_program_or_bare_string(text) - .map(|node| node.kind) - .map_err(|err| err.kind) - } - - fn parse_normalized(text: &str) -> ExpressionNode<'_> { - normalize_tree(parse_program(text).unwrap()) - } - - fn parse_maybe_bare_normalized(text: &str) -> ExpressionNode<'_> { - normalize_tree(parse_program_or_bare_string(text).unwrap()) - } - - /// Drops auxiliary data from parsed tree so it can be compared with other. - fn normalize_tree(node: ExpressionNode) -> ExpressionNode { - fn empty_span() -> pest::Span<'static> { - pest::Span::new("", 0, 0).unwrap() - } - - fn normalize_list(nodes: Vec) -> Vec { - nodes.into_iter().map(normalize_tree).collect() - } - - fn normalize_function_call(function: FunctionCallNode) -> FunctionCallNode { - FunctionCallNode { - name: function.name, - name_span: empty_span(), - args: normalize_list(function.args), - keyword_args: function - .keyword_args - .into_iter() - .map(|arg| KeywordArgument { - name: arg.name, - name_span: empty_span(), - value: normalize_tree(arg.value), - }) - .collect(), - args_span: empty_span(), - } - } - - let normalized_kind = match node.kind { - ExpressionKind::Identifier(_) | ExpressionKind::String(_) => node.kind, - ExpressionKind::Pattern(pattern) => { - let pattern = Box::new(PatternNode { - name: pattern.name, - name_span: empty_span(), - value: normalize_tree(pattern.value), - }); - ExpressionKind::Pattern(pattern) - } - ExpressionKind::Unary(op, arg) => { - let arg = Box::new(normalize_tree(*arg)); - ExpressionKind::Unary(op, arg) - } - ExpressionKind::Binary(op, lhs, rhs) => { - let lhs = Box::new(normalize_tree(*lhs)); - let rhs = Box::new(normalize_tree(*rhs)); - ExpressionKind::Binary(op, lhs, rhs) - } - ExpressionKind::UnionAll(nodes) => { - let nodes = normalize_list(nodes); - ExpressionKind::UnionAll(nodes) - } - ExpressionKind::FunctionCall(function) => { - let function = Box::new(normalize_function_call(*function)); - ExpressionKind::FunctionCall(function) - } - ExpressionKind::AliasExpanded(_, subst) => normalize_tree(*subst).kind, - }; - ExpressionNode { - kind: normalized_kind, - span: empty_span(), - } - } - - #[test] - fn test_parse_tree_eq() { - assert_eq!( - parse_normalized(r#" foo( x ) | ~bar:"baz" "#), - parse_normalized(r#"(foo(x))|(~(bar:"baz"))"#) - ); - assert_ne!(parse_normalized(r#" foo "#), parse_normalized(r#" "foo" "#)); - } - - #[test] - fn test_parse_invalid_function_name() { - assert_eq!( - parse_into_kind("5foo(x)"), - Err(FilesetParseErrorKind::SyntaxError) - ); - } - - #[test] - fn test_parse_whitespace() { - let ascii_whitespaces: String = ('\x00'..='\x7f') - .filter(char::is_ascii_whitespace) - .collect(); - assert_eq!( - parse_normalized(&format!("{ascii_whitespaces}f()")), - parse_normalized("f()") - ); - } - - #[test] - fn test_parse_identifier() { - assert_eq!( - parse_into_kind("dir/foo-bar_0.baz"), - Ok(ExpressionKind::Identifier("dir/foo-bar_0.baz")) - ); - assert_eq!( - parse_into_kind("cli-reference@.md.snap"), - Ok(ExpressionKind::Identifier("cli-reference@.md.snap")) - ); - assert_eq!( - parse_into_kind("柔術.jj"), - Ok(ExpressionKind::Identifier("柔術.jj")) - ); - assert_eq!( - parse_into_kind(r#"Windows\Path"#), - Ok(ExpressionKind::Identifier(r#"Windows\Path"#)) - ); - assert_eq!( - parse_into_kind("glob*[chars]?"), - Ok(ExpressionKind::Identifier("glob*[chars]?")) - ); - } - - #[test] - fn test_parse_string_literal() { - // "\" escapes - assert_eq!( - parse_into_kind(r#" "\t\r\n\"\\\0\e" "#), - Ok(ExpressionKind::String("\t\r\n\"\\\0\u{1b}".to_owned())), - ); - - // Invalid "\" escape - assert_eq!( - parse_into_kind(r#" "\y" "#), - Err(FilesetParseErrorKind::SyntaxError), - ); - - // Single-quoted raw string - assert_eq!( - parse_into_kind(r#" '' "#), - Ok(ExpressionKind::String("".to_owned())), - ); - assert_eq!( - parse_into_kind(r#" 'a\n' "#), - Ok(ExpressionKind::String(r"a\n".to_owned())), - ); - assert_eq!( - parse_into_kind(r#" '\' "#), - Ok(ExpressionKind::String(r"\".to_owned())), - ); - assert_eq!( - parse_into_kind(r#" '"' "#), - Ok(ExpressionKind::String(r#"""#.to_owned())), - ); - - // Hex bytes - assert_eq!( - parse_into_kind(r#""\x61\x65\x69\x6f\x75""#), - Ok(ExpressionKind::String("aeiou".to_owned())), - ); - assert_eq!( - parse_into_kind(r#""\xe0\xe8\xec\xf0\xf9""#), - Ok(ExpressionKind::String("àèìðù".to_owned())), - ); - assert_eq!( - parse_into_kind(r#""\x""#), - Err(FilesetParseErrorKind::SyntaxError), - ); - assert_eq!( - parse_into_kind(r#""\xf""#), - Err(FilesetParseErrorKind::SyntaxError), - ); - assert_eq!( - parse_into_kind(r#""\xgg""#), - Err(FilesetParseErrorKind::SyntaxError), - ); - } - - #[test] - fn test_parse_pattern() -> TestResult { - fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { - match kind { - ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), - _ => panic!("unexpected expression: {kind:?}"), - } - } - - assert_eq!( - unwrap_pattern(parse_into_kind(r#" foo:bar "#)?), - ("foo", ExpressionKind::Identifier("bar")) - ); - assert_eq!( - unwrap_pattern(parse_into_kind(" foo:glob*[chars]? ")?), - ("foo", ExpressionKind::Identifier("glob*[chars]?")) - ); - assert_eq!( - unwrap_pattern(parse_into_kind(r#" foo:"bar" "#)?), - ("foo", ExpressionKind::String("bar".to_owned())) - ); - assert_eq!( - unwrap_pattern(parse_into_kind(r#" foo:"" "#)?), - ("foo", ExpressionKind::String("".to_owned())) - ); - assert_eq!( - unwrap_pattern(parse_into_kind(r#" foo:'\' "#)?), - ("foo", ExpressionKind::String(r"\".to_owned())) - ); - assert_eq!( - parse_into_kind(r#" foo: "#), - Err(FilesetParseErrorKind::SyntaxError) - ); - - // Whitespace isn't allowed in between - assert_eq!( - parse_into_kind(r#" foo: "" "#), - Err(FilesetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_into_kind(r#" foo :"" "#), - Err(FilesetParseErrorKind::SyntaxError) - ); - // Whitespace is allowed in parenthesized value expression - assert_eq!( - parse_normalized("foo:( 'bar' )"), - parse_normalized("foo:'bar'") - ); - - // Functions are allowed - assert_eq!(parse_normalized("x:f(y)"), parse_normalized("x:(f(y))")); - // Logical operators have lower binding strength - assert_eq!(parse_normalized("x:y&z"), parse_normalized("(x:y)&(z)")); - assert_matches!( - parse_into_kind("x:~y"), // (x:) ~ (y) - Err(FilesetParseErrorKind::SyntaxError) - ); - - // Pattern prefix is like (type)x cast, so is evaluated from right - assert_eq!(parse_normalized("x:y:z"), parse_normalized("x:(y:z)")); - Ok(()) - } - - #[test] - fn test_parse_operator() -> TestResult { - assert_matches!( - parse_into_kind("~x"), - Ok(ExpressionKind::Unary(UnaryOp::Negate, _)) - ); - assert_matches!( - parse_into_kind("x|y"), - Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 2 - ); - assert_matches!( - parse_into_kind("x|y|z"), - Ok(ExpressionKind::UnionAll(nodes)) if nodes.len() == 3 - ); - assert_matches!( - parse_into_kind("x&y"), - Ok(ExpressionKind::Binary(BinaryOp::Intersection, _, _)) - ); - assert_matches!( - parse_into_kind("x~y"), - Ok(ExpressionKind::Binary(BinaryOp::Difference, _, _)) - ); - - // Set operator associativity/precedence - assert_eq!(parse_normalized("~x|y"), parse_normalized("(~x)|y")); - assert_eq!(parse_normalized("x&~y"), parse_normalized("x&(~y)")); - assert_eq!(parse_normalized("x~~y"), parse_normalized("x~(~y)")); - assert_eq!(parse_normalized("x~~~y"), parse_normalized("x~(~(~y))")); - assert_eq!(parse_normalized("x|y|z"), parse_normalized("(x|y)|z")); - assert_eq!(parse_normalized("x&y|z"), parse_normalized("(x&y)|z")); - assert_eq!(parse_normalized("x|y&z"), parse_normalized("x|(y&z)")); - assert_eq!(parse_normalized("x|y~z"), parse_normalized("x|(y~z)")); - assert_eq!(parse_normalized("~x:y"), parse_normalized("~(x:y)")); - assert_eq!(parse_normalized("x|y:z"), parse_normalized("x|(y:z)")); - - // Expression span - assert_eq!(parse_program(" ~ x ")?.span.as_str(), "~ x"); - assert_eq!(parse_program(" x |y ")?.span.as_str(), "x |y"); - assert_eq!(parse_program(" (x) ")?.span.as_str(), "(x)"); - assert_eq!(parse_program("~( x|y) ")?.span.as_str(), "~( x|y)"); - Ok(()) - } - - #[test] - fn test_parse_function_call() -> TestResult { - fn unwrap_function_call(node: ExpressionNode<'_>) -> Box> { - match node.kind { - ExpressionKind::FunctionCall(function) => function, - _ => panic!("unexpected expression: {node:?}"), - } - } - - assert_matches!( - parse_into_kind("foo()"), - Ok(ExpressionKind::FunctionCall(_)) - ); - - // Trailing comma isn't allowed for empty argument - assert!(parse_into_kind("foo(,)").is_err()); - - // Trailing comma is allowed for the last argument - assert_eq!(parse_normalized("foo(a,)"), parse_normalized("foo(a)")); - assert_eq!(parse_normalized("foo(a , )"), parse_normalized("foo(a)")); - assert!(parse_into_kind("foo(,a)").is_err()); - assert!(parse_into_kind("foo(a,,)").is_err()); - assert!(parse_into_kind("foo(a , , )").is_err()); - assert_eq!(parse_normalized("foo(a,b,)"), parse_normalized("foo(a,b)")); - assert!(parse_into_kind("foo(a,,b)").is_err()); - - // Expression span - let function = unwrap_function_call(parse_program("foo( a, (b) , ~(c) )")?); - assert_eq!(function.name_span.as_str(), "foo"); - assert_eq!(function.args_span.as_str(), "a, (b) , ~(c)"); - assert_eq!(function.args[0].span.as_str(), "a"); - assert_eq!(function.args[1].span.as_str(), "(b)"); - assert_eq!(function.args[2].span.as_str(), "~(c)"); - Ok(()) - } - - #[test] - fn test_parse_bare_string() -> TestResult { - fn unwrap_pattern(kind: ExpressionKind<'_>) -> (&str, ExpressionKind<'_>) { - match kind { - ExpressionKind::Pattern(pattern) => (pattern.name, pattern.value.kind), - _ => panic!("unexpected expression: {kind:?}"), - } - } - - // Valid expression should be parsed as such - assert_eq!( - parse_maybe_bare_into_kind(" valid "), - Ok(ExpressionKind::Identifier("valid")) - ); - assert_eq!( - parse_maybe_bare_normalized("f(x)&y"), - parse_normalized("f(x)&y") - ); - assert_eq!( - unwrap_pattern(parse_maybe_bare_into_kind("foo:bar")?), - ("foo", ExpressionKind::Identifier("bar")) - ); - - // Bare string - assert_eq!( - parse_maybe_bare_into_kind("Foo Bar.txt"), - Ok(ExpressionKind::String("Foo Bar.txt".to_owned())) - ); - assert_eq!( - parse_maybe_bare_into_kind(r#"Windows\Path with space"#), - Ok(ExpressionKind::String( - r#"Windows\Path with space"#.to_owned() - )) - ); - assert_eq!( - parse_maybe_bare_into_kind("柔 術 . j j"), - Ok(ExpressionKind::String("柔 術 . j j".to_owned())) - ); - assert_eq!( - parse_maybe_bare_into_kind("Unicode emoji 💩"), - Ok(ExpressionKind::String("Unicode emoji 💩".to_owned())) - ); - assert_eq!( - parse_maybe_bare_into_kind("looks like & expression"), - Err(FilesetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_maybe_bare_into_kind("unbalanced_parens("), - Err(FilesetParseErrorKind::SyntaxError) - ); - - // Bare string pattern - assert_eq!( - unwrap_pattern(parse_maybe_bare_into_kind("foo: bar baz")?), - ("foo", ExpressionKind::String(" bar baz".to_owned())) - ); - assert_eq!( - unwrap_pattern(parse_maybe_bare_into_kind("foo:glob * [chars]?")?), - ("foo", ExpressionKind::String("glob * [chars]?".to_owned())) - ); - assert_eq!( - parse_maybe_bare_into_kind("foo: bar:baz"), - Err(FilesetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_maybe_bare_into_kind("foo:"), - Err(FilesetParseErrorKind::SyntaxError) - ); - assert_eq!( - parse_maybe_bare_into_kind(r#"foo:"unclosed quote"#), - Err(FilesetParseErrorKind::SyntaxError) - ); - - // Surrounding spaces are simply preserved. They could be trimmed, but - // space is valid bare_string character. - assert_eq!( - parse_maybe_bare_into_kind(" No trim "), - Ok(ExpressionKind::String(" No trim ".to_owned())) - ); - Ok(()) - } - - #[test] - fn test_parse_error() { - insta::assert_snapshot!(parse_program("foo|").unwrap_err().to_string(), @" - --> 1:5 - | - 1 | foo| - | ^--- - | - = expected `~` or - "); - } - - #[test] - fn test_parse_alias_symbol_decl() -> TestResult { - let mut aliases_map = FilesetAliasesMap::new(); - aliases_map.insert("sym", "symbol", None)?; - assert_eq!(aliases_map.symbol_names().count(), 1); - let (id, defn, _doc) = aliases_map.get_symbol("sym").unwrap(); - assert_eq!(id, AliasId::Symbol("sym")); - assert_eq!(defn, "symbol"); - - // Non-ASCII character isn't allowed in alias symbol. This rule can be - // relaxed if needed. - assert!(aliases_map.insert("柔術", "none()", None).is_err()); - Ok(()) - } - - #[test] - fn test_parse_alias_pattern_decl() -> TestResult { - let mut aliases_map = FilesetAliasesMap::new(); - assert!(aliases_map.insert("pat:", "bad_pattern", None).is_err()); - aliases_map.insert("pat:a", "pattern_a", None)?; - aliases_map.insert("pat:b", "pattern_b", None)?; - assert_eq!(aliases_map.pattern_names().count(), 1); - let (id, param, defn, _doc) = aliases_map.get_pattern("pat").unwrap(); - assert_eq!(id, AliasId::Pattern("pat", "b")); - assert_eq!(param, "b"); - assert_eq!(defn, "pattern_b"); - - // Non-ASCII character isn't allowed. This rule can be relaxed if - // needed. - assert!(aliases_map.insert("柔術:x", "none()", None).is_err()); - assert!(aliases_map.insert("x:柔術", "none()", None).is_err()); - Ok(()) - } - - #[test] - fn test_parse_alias_func_decl() -> TestResult { - let mut aliases_map = FilesetAliasesMap::new(); - assert!(aliases_map.insert("5func()", "bad_function", None).is_err()); - aliases_map.insert("func()", "function_0", None)?; - aliases_map.insert("func(a)", "function_1a", None)?; - aliases_map.insert("func(b)", "function_1b", None)?; - aliases_map.insert("func(a, b)", "function_2", None)?; - assert_eq!(aliases_map.function_names().count(), 1); - - let (id, params, defn, _doc) = aliases_map.get_function("func", 0).unwrap(); - assert_eq!(id, AliasId::Function("func", &[])); - assert!(params.is_empty()); - assert_eq!(defn, "function_0"); - - let (id, params, defn, _doc) = aliases_map.get_function("func", 1).unwrap(); - assert_eq!(id, AliasId::Function("func", &["b".to_owned()])); - assert_eq!(params, ["b"]); - assert_eq!(defn, "function_1b"); - - let (id, params, defn, _doc) = aliases_map.get_function("func", 2).unwrap(); - assert_eq!( - id, - AliasId::Function("func", &["a".to_owned(), "b".to_owned()]) - ); - assert_eq!(params, ["a", "b"]); - assert_eq!(defn, "function_2"); - - assert!(aliases_map.get_function("func", 3).is_none()); - Ok(()) - } - - #[test] - fn test_parse_alias_formal_parameter() { - let mut aliases_map = FilesetAliasesMap::new(); - // Formal parameter 'a' can't be redefined - assert_eq!( - aliases_map.insert("f(a, a)", "bad", None).unwrap_err().kind, - FilesetParseErrorKind::RedefinedFunctionParameter - ); - // Trailing comma isn't allowed for empty parameter - assert!(aliases_map.insert("f(,)", "bad", None).is_err()); - // Trailing comma is allowed for the last parameter - assert!(aliases_map.insert("g(a,)", "bad", None).is_ok()); - assert!(aliases_map.insert("h(a , )", "bad", None).is_ok()); - assert!(aliases_map.insert("i(,a)", "bad", None).is_err()); - assert!(aliases_map.insert("j(a,,)", "bad", None).is_err()); - assert!(aliases_map.insert("k(a , , )", "bad", None).is_err()); - assert!(aliases_map.insert("l(a,b,)", "bad", None).is_ok()); - assert!(aliases_map.insert("m(a,,b)", "bad", None).is_err()); - } - - #[test] - fn test_expand_symbol_alias() { - assert_eq!( - with_aliases([("AB", "a&b")]).parse_normalized("AB|c"), - parse_normalized("(a&b)|c") - ); - assert_eq!( - with_aliases([("AB", "a|b")]).parse_normalized("AB~f(AB)"), - parse_normalized("(a|b)~f(a|b)") - ); - - // Not string substitution 'a&b|c', but tree substitution. - assert_eq!( - with_aliases([("BC", "b|c")]).parse_normalized("a&BC"), - parse_normalized("a&(b|c)") - ); - - // String literal should not be substituted with alias. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized(r#"A|"A"|'A'"#), - parse_normalized("a|'A'|'A'") - ); - - // Kind of string pattern should not be substituted, which is similar to - // function name. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("A:b"), - parse_normalized("A:b") - ); - - // Value of string pattern can be substituted if it's an identifier. - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("p:A"), - parse_normalized("p:a") - ); - assert_eq!( - with_aliases([("A", "a")]).parse_normalized("p:'A'"), - parse_normalized("p:'A'") - ); - - // Multi-level substitution. - assert_eq!( - with_aliases([("A", "BC"), ("BC", "b|C"), ("C", "c")]).parse_normalized("A"), - parse_normalized("b|c") - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - with_aliases([("A", "A")]).parse("A").unwrap_err().kind, - FilesetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - assert_eq!( - with_aliases([("A", "B"), ("B", "b|C"), ("C", "c|B")]) - .parse("A") - .unwrap_err() - .kind, - FilesetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - - // Error in alias definition. - assert_eq!( - with_aliases([("A", "a(")]).parse("A").unwrap_err().kind, - FilesetParseErrorKind::InAliasExpansion("A".to_owned()) - ); - } - - #[test] - fn test_expand_pattern_alias() { - assert_eq!( - with_aliases([("P:x", "x")]).parse_normalized("P:a"), - parse_normalized("a") - ); - - // Argument should be resolved in the current scope. - assert_eq!( - with_aliases([("P:x", "x|a")]).parse_normalized("P:x"), - parse_normalized("x|a") - ); - // P:a -> (Q:a)&y -> (x|a)&y - assert_eq!( - with_aliases([("P:x", "(Q:x)&y"), ("Q:y", "x|y")]).parse_normalized("P:a"), - parse_normalized("(x|a)&y") - ); - - // Pattern parameter should precede the symbol alias. - assert_eq!( - with_aliases([("P:X", "X"), ("X", "x")]).parse_normalized("(P:a)|X"), - parse_normalized("a|x") - ); - - // Pattern parameter shouldn't be expanded in symbol alias. - assert_eq!( - with_aliases([("P:x", "x|A"), ("A", "x")]).parse_normalized("P:a"), - parse_normalized("a|x") - ); - - // String literal should not be substituted with pattern parameter. - assert_eq!( - with_aliases([("P:x", "x|'x'")]).parse_normalized("P:a"), - parse_normalized("a|'x'") - ); - - // Pattern and symbol aliases reside in separate namespaces. - assert_eq!( - with_aliases([("A:x", "A"), ("A", "a")]).parse_normalized("A:x"), - parse_normalized("a") - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - with_aliases([("P:x", "Q:x"), ("Q:x", "R:x"), ("R:x", "P:x")]) - .parse("P:a") - .unwrap_err() - .kind, - FilesetParseErrorKind::InAliasExpansion("P:x".to_owned()) - ); - } - - #[test] - fn test_expand_function_alias() { - assert_eq!( - with_aliases([("F( )", "a")]).parse_normalized("F()"), - parse_normalized("a") - ); - assert_eq!( - with_aliases([("F( x )", "x")]).parse_normalized("F(a)"), - parse_normalized("a") - ); - assert_eq!( - with_aliases([("F( x, y )", "x|y")]).parse_normalized("F(a, b)"), - parse_normalized("a|b") - ); - - // Not recursion because functions are overloaded by arity. - assert_eq!( - with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "x|y")]).parse_normalized("F(a)"), - parse_normalized("a|b") - ); - - // Arguments should be resolved in the current scope. - assert_eq!( - with_aliases([("F(x,y)", "x|y")]).parse_normalized("F(a~y,b~x)"), - parse_normalized("(a~y)|(b~x)") - ); - // F(a) -> G(a)&y -> (x|a)&y - assert_eq!( - with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(a)"), - parse_normalized("(x|a)&y") - ); - // F(G(a)) -> F(x|a) -> G(x|a)&y -> (x|(x|a))&y - assert_eq!( - with_aliases([("F(x)", "G(x)&y"), ("G(y)", "x|y")]).parse_normalized("F(G(a))"), - parse_normalized("(x|(x|a))&y") - ); - - // Function parameter should precede the symbol alias. - assert_eq!( - with_aliases([("F(X)", "X"), ("X", "x")]).parse_normalized("F(a)|X"), - parse_normalized("a|x") - ); - - // Function parameter shouldn't be expanded in symbol alias. - assert_eq!( - with_aliases([("F(x)", "x|A"), ("A", "x")]).parse_normalized("F(a)"), - parse_normalized("a|x") - ); - - // String literal should not be substituted with function parameter. - assert_eq!( - with_aliases([("F(x)", "x|'x'")]).parse_normalized("F(a)"), - parse_normalized("a|'x'") - ); - - // Function and symbol aliases reside in separate namespaces. - assert_eq!( - with_aliases([("A()", "A"), ("A", "a")]).parse_normalized("A()"), - parse_normalized("a") - ); - - // Invalid number of arguments. - assert_eq!( - with_aliases([("F()", "x")]).parse("F(a)").unwrap_err().kind, - FilesetParseErrorKind::InvalidArguments { - name: "F".to_owned(), - message: "Expected 0 arguments".to_owned() - } - ); - assert_eq!( - with_aliases([("F(x)", "x")]).parse("F()").unwrap_err().kind, - FilesetParseErrorKind::InvalidArguments { - name: "F".to_owned(), - message: "Expected 1 arguments".to_owned() - } - ); - assert_eq!( - with_aliases([("F(x,y)", "x|y")]) - .parse("F(a,b,c)") - .unwrap_err() - .kind, - FilesetParseErrorKind::InvalidArguments { - name: "F".to_owned(), - message: "Expected 2 arguments".to_owned() - } - ); - assert_eq!( - with_aliases([("F(x)", "x"), ("F(x,y)", "x|y")]) - .parse("F()") - .unwrap_err() - .kind, - FilesetParseErrorKind::InvalidArguments { - name: "F".to_owned(), - message: "Expected 1 to 2 arguments".to_owned() - } - ); - assert_eq!( - with_aliases([("F()", "x"), ("F(x,y)", "x|y")]) - .parse("F(a)") - .unwrap_err() - .kind, - FilesetParseErrorKind::InvalidArguments { - name: "F".to_owned(), - message: "Expected 0, 2 arguments".to_owned() - } - ); - - // Infinite recursion, where the top-level error isn't of RecursiveAlias kind. - assert_eq!( - with_aliases([("F(x)", "G(x)"), ("G(x)", "H(x)"), ("H(x)", "F(x)")]) - .parse("F(a)") - .unwrap_err() - .kind, - FilesetParseErrorKind::InAliasExpansion("F(x)".to_owned()) - ); - assert_eq!( - with_aliases([("F(x)", "F(x,b)"), ("F(x,y)", "F(x|y)")]) - .parse("F(a)") - .unwrap_err() - .kind, - FilesetParseErrorKind::InAliasExpansion("F(x)".to_owned()) - ); - } -} +// Allow unused imports here, because all symbols moved to the core crate and we need to +// continue to reexport the same APIs. +#![expect(unused_imports)] + +pub use jj_core::fileset_parser::BinaryOp; +pub use jj_core::fileset_parser::ExpressionKind; +pub use jj_core::fileset_parser::ExpressionNode; +pub use jj_core::fileset_parser::FilesetAliasParser; +pub use jj_core::fileset_parser::FilesetAliasesMap; +pub use jj_core::fileset_parser::FilesetDiagnostics; +pub use jj_core::fileset_parser::FilesetParseError; +pub use jj_core::fileset_parser::FilesetParseErrorKind; +pub use jj_core::fileset_parser::FilesetParseResult; +pub use jj_core::fileset_parser::FunctionCallNode; +pub use jj_core::fileset_parser::PatternNode; +pub use jj_core::fileset_parser::Rule; +pub use jj_core::fileset_parser::UnaryOp; +pub use jj_core::fileset_parser::catch_aliases; +pub use jj_core::fileset_parser::expand_aliases; +pub use jj_core::fileset_parser::expect_string_literal; +pub use jj_core::fileset_parser::parse_program; +pub use jj_core::fileset_parser::parse_program_or_bare_string; From 020d84778c4a243519d28c2c3e7dcb91c5faf9ea Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Sun, 7 Jun 2026 20:03:58 +0200 Subject: [PATCH 8/8] core: Lower the `WorkspaceStore` into the crate Moving this is quite simple and adds another trait to the core crate. Part of #6284 --- lib/core/src/backend.rs | 21 ++++++++++++- lib/core/src/lib.rs | 1 + lib/core/src/workspace_store.rs | 56 +++++++++++++++++++++++++++++++++ lib/src/backend.rs | 20 +----------- lib/src/repo_path.rs | 1 + lib/src/workspace_store.rs | 35 ++------------------- 6 files changed, 81 insertions(+), 53 deletions(-) create mode 100644 lib/core/src/workspace_store.rs diff --git a/lib/core/src/backend.rs b/lib/core/src/backend.rs index ccc3b158dcd..ddd389cdc76 100644 --- a/lib/core/src/backend.rs +++ b/lib/core/src/backend.rs @@ -16,11 +16,30 @@ //! [`CommitId`]. // TODO: move the `Backend` trait into this. +use crate::hex_util; use crate::object_id::ObjectId as _; use crate::object_id::id_type; id_type!( - /// Identifier for a [`Commit`] based on its content. When a commit is + /// Identifier for a `Commit` based on its content. When a commit is /// rewritten, its `CommitId` changes. pub CommitId { hex() } ); +id_type!( + /// Stable identifier for a `Commit`. Unlike the `CommitId`, the `ChangeId` + /// follows the commit and is not updated when the commit is rewritten. + pub ChangeId { reverse_hex() } +); + +impl ChangeId { + /// Parses the given "reverse" hex string into a `ChangeId`. + pub fn try_from_reverse_hex(hex: impl AsRef<[u8]>) -> Option { + hex_util::decode_reverse_hex(hex).map(Self) + } + + /// Returns the hex string representation of this ID, which uses `z-k` + /// "digits" instead of `0-9a-f`. + pub fn reverse_hex(&self) -> String { + hex_util::encode_reverse_hex(&self.0) + } +} diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index f1852278f5f..8a242ce4a28 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -41,6 +41,7 @@ pub mod repo_path; pub mod revset; pub mod revset_parser; pub mod signing; +pub mod workspace_store; #[cfg(test)] mod tests { diff --git a/lib/core/src/workspace_store.rs b/lib/core/src/workspace_store.rs new file mode 100644 index 00000000000..a1f2f6bbe79 --- /dev/null +++ b/lib/core/src/workspace_store.rs @@ -0,0 +1,56 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Workspace store for managing workspace metadata. + +use std::fmt::Debug; +use std::path::Path; +use std::path::PathBuf; + +use thiserror::Error; + +use crate::ref_name::WorkspaceName; + +/// Errors that can occur when interacting with a workspace store. +#[derive(Error, Debug)] +pub enum WorkspaceStoreError { + /// An unspecified error occurred. + #[error(transparent)] + Other(#[from] Box), +} + +/// A storage backend for workspace metadata. +pub trait WorkspaceStore: Send + Sync + Debug { + /// Returns the name of this workspace store implementation. + fn name(&self) -> &str; + + /// Adds a workspace with the given name and path to the store. + fn add(&self, workspace_name: &WorkspaceName, path: &Path) -> Result<(), WorkspaceStoreError>; + + /// Forgets the workspaces with the given names. + fn forget(&self, workspace_names: &[&WorkspaceName]) -> Result<(), WorkspaceStoreError>; + + /// Renames a workspace from `old_name` to `new_name`. + fn rename( + &self, + old_name: &WorkspaceName, + new_name: &WorkspaceName, + ) -> Result<(), WorkspaceStoreError>; + + /// Gets the path of the workspace with the given name, if it exists. + fn get_workspace_path( + &self, + workspace_name: &WorkspaceName, + ) -> Result, WorkspaceStoreError>; +} diff --git a/lib/src/backend.rs b/lib/src/backend.rs index f3b84bb2362..cf4b9299611 100644 --- a/lib/src/backend.rs +++ b/lib/src/backend.rs @@ -25,11 +25,11 @@ use async_trait::async_trait; use chrono::TimeZone as _; use futures::AsyncRead; use futures::stream::BoxStream; +pub use jj_core::backend::ChangeId; pub use jj_core::backend::CommitId; use thiserror::Error; use crate::content_hash::ContentHash; -use crate::hex_util; use crate::index::Index; use crate::merge::Merge; use crate::object_id::ObjectId as _; @@ -40,11 +40,6 @@ use crate::repo_path::RepoPathComponent; use crate::repo_path::RepoPathComponentBuf; use crate::signing::SignResult; -id_type!( - /// Stable identifier for a [`Commit`]. Unlike the `CommitId`, the `ChangeId` - /// follows the commit and is not updated when the commit is rewritten. - pub ChangeId { reverse_hex() } -); id_type!( /// Identifier for a tree object. pub TreeId { hex() } @@ -62,19 +57,6 @@ id_type!( pub CopyId { hex() } ); -impl ChangeId { - /// Parses the given "reverse" hex string into a `ChangeId`. - pub fn try_from_reverse_hex(hex: impl AsRef<[u8]>) -> Option { - hex_util::decode_reverse_hex(hex).map(Self) - } - - /// Returns the hex string representation of this ID, which uses `z-k` - /// "digits" instead of `0-9a-f`. - pub fn reverse_hex(&self) -> String { - hex_util::encode_reverse_hex(&self.0) - } -} - impl CopyId { /// Returns a placeholder copy id to be used when we don't have a real copy /// id yet. diff --git a/lib/src/repo_path.rs b/lib/src/repo_path.rs index 7c22ac265d8..096d0cab87a 100644 --- a/lib/src/repo_path.rs +++ b/lib/src/repo_path.rs @@ -229,6 +229,7 @@ impl Debug for RepoPathTree { mod tests { use super::*; + fn repo_path(value: &str) -> &RepoPath { RepoPath::from_internal_string(value).unwrap() } diff --git a/lib/src/workspace_store.rs b/lib/src/workspace_store.rs index 32e6072b4be..2adf9a29392 100644 --- a/lib/src/workspace_store.rs +++ b/lib/src/workspace_store.rs @@ -20,6 +20,8 @@ use std::io::Write as _; use std::path::Path; use std::path::PathBuf; +pub use jj_core::workspace_store::WorkspaceStore; +pub use jj_core::workspace_store::WorkspaceStoreError; use jj_lib::file_util::BadPathEncoding; use jj_lib::file_util::IoResultExt as _; use jj_lib::file_util::PathError; @@ -36,39 +38,6 @@ use prost::Message as _; use tempfile::NamedTempFile; use thiserror::Error; -/// Errors that can occur when interacting with a workspace store. -#[derive(Error, Debug)] -pub enum WorkspaceStoreError { - /// An unspecified error occurred. - #[error(transparent)] - Other(#[from] Box), -} - -/// A storage backend for workspace metadata. -pub trait WorkspaceStore: Send + Sync + Debug { - /// Returns the name of this workspace store implementation. - fn name(&self) -> &str; - - /// Adds a workspace with the given name and path to the store. - fn add(&self, workspace_name: &WorkspaceName, path: &Path) -> Result<(), WorkspaceStoreError>; - - /// Forgets the workspaces with the given names. - fn forget(&self, workspace_names: &[&WorkspaceName]) -> Result<(), WorkspaceStoreError>; - - /// Renames a workspace from `old_name` to `new_name`. - fn rename( - &self, - old_name: &WorkspaceName, - new_name: &WorkspaceName, - ) -> Result<(), WorkspaceStoreError>; - - /// Gets the path of the workspace with the given name, if it exists. - fn get_workspace_path( - &self, - workspace_name: &WorkspaceName, - ) -> Result, WorkspaceStoreError>; -} - /// Errors specific to the `SimpleWorkspaceStore` implementation. #[derive(Error, Debug)] pub enum SimpleWorkspaceStoreError {