changeset 52760:94e2547e6f3d

rust: move code from utils to utils::strings This moves string-related functions in hg::utils into the recently added hg::utils::strings module.
author Mitchell Kember <mkember@janestreet.com>
date Thu, 16 Jan 2025 13:15:02 -0500
parents 36d39726c0af
children 8497cfb0d76c
files rust/hg-core/src/config/layer.rs rust/hg-core/src/config/values.rs rust/hg-core/src/encoding.rs rust/hg-core/src/filepatterns.rs rust/hg-core/src/matchers.rs rust/hg-core/src/repo.rs rust/hg-core/src/requirements.rs rust/hg-core/src/revlog/filelog.rs rust/hg-core/src/revlog/manifest.rs rust/hg-core/src/sparse.rs rust/hg-core/src/utils.rs rust/hg-core/src/utils/files.rs rust/hg-core/src/utils/hg_path.rs rust/hg-core/src/utils/path_auditor.rs rust/hg-core/src/utils/strings.rs rust/rhg/src/blackbox.rs rust/rhg/src/commands/config.rs rust/rhg/src/main.rs
diffstat 18 files changed, 318 insertions(+), 316 deletions(-) [+]
line wrap: on
line diff
--- a/rust/hg-core/src/config/layer.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/config/layer.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -57,7 +57,7 @@
         cli_config_args: impl IntoIterator<Item = impl AsRef<[u8]>>,
     ) -> Result<Option<Self>, ConfigError> {
         fn parse_one(arg: &[u8]) -> Option<(Vec<u8>, Vec<u8>, Vec<u8>)> {
-            use crate::utils::SliceExt;
+            use crate::utils::strings::SliceExt;
 
             let (section_and_item, value) = arg.split_2(b'=')?;
             let (section, item) = section_and_item.trim().split_2(b'.')?;
@@ -169,7 +169,8 @@
             let line = Some(index + 1);
             if let Some(m) = INCLUDE_RE.captures(bytes) {
                 let filename_bytes = &m[1];
-                let filename_bytes = crate::utils::expand_vars(filename_bytes);
+                let filename_bytes =
+                    crate::utils::strings::expand_vars(filename_bytes);
                 // `Path::parent` only fails for the root directory,
                 // which `src` can’t be since we’ve managed to open it as a
                 // file.
--- a/rust/hg-core/src/config/values.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/config/values.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -8,7 +8,7 @@
 //! details about where the value came from (but omits details of what’s
 //! invalid inside the value).
 
-use crate::utils::SliceExt;
+use crate::utils::strings::SliceExt;
 
 pub(super) fn parse_bool(v: &[u8]) -> Option<bool> {
     match v.to_ascii_lowercase().as_slice() {
--- a/rust/hg-core/src/encoding.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/encoding.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -3,7 +3,7 @@
 use core::str;
 use std::borrow::Cow;
 
-use crate::{errors::HgError, utils::Escaped};
+use crate::{errors::HgError, utils::strings::Escaped};
 use unicode_width::UnicodeWidthStr as _;
 
 /// String encoder and decoder.
--- a/rust/hg-core/src/filepatterns.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/filepatterns.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -12,7 +12,7 @@
     utils::{
         files::{canonical_path, get_bytes_from_path, get_path_from_bytes},
         hg_path::{path_to_hg_path_buf, HgPathBuf, HgPathError},
-        SliceExt,
+        strings::SliceExt,
     },
     FastHashMap,
 };
--- a/rust/hg-core/src/matchers.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/matchers.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -24,7 +24,7 @@
     utils::{
         files::{dir_ancestors, find_dirs},
         hg_path::{HgPath, HgPathBuf, HgPathError},
-        Escaped,
+        strings::Escaped,
     },
     FastHashMap,
 };
--- a/rust/hg-core/src/repo.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/repo.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -15,7 +15,7 @@
 use crate::utils::debug::debug_wait_for_file_or_print;
 use crate::utils::files::get_path_from_bytes;
 use crate::utils::hg_path::HgPath;
-use crate::utils::SliceExt;
+use crate::utils::strings::SliceExt;
 use crate::vfs::{is_dir, is_file, Vfs, VfsImpl};
 use crate::{exit_codes, requirements, NodePrefix, UncheckedRevision};
 use std::cell::{Ref, RefCell, RefMut};
--- a/rust/hg-core/src/requirements.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/requirements.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -1,6 +1,6 @@
 use crate::errors::{HgError, HgResultExt};
 use crate::repo::Repo;
-use crate::utils::join_display;
+use crate::utils::strings::join_display;
 use crate::vfs::VfsImpl;
 use std::collections::HashSet;
 
--- a/rust/hg-core/src/revlog/filelog.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/revlog/filelog.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -8,7 +8,7 @@
 use crate::revlog::{Revlog, RevlogError};
 use crate::utils::files::get_path_from_bytes;
 use crate::utils::hg_path::HgPath;
-use crate::utils::SliceExt;
+use crate::utils::strings::SliceExt;
 use crate::Graph;
 use crate::GraphError;
 use crate::Node;
--- a/rust/hg-core/src/revlog/manifest.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/revlog/manifest.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -4,7 +4,7 @@
 use crate::revlog::{Node, NodePrefix};
 use crate::revlog::{Revlog, RevlogError};
 use crate::utils::hg_path::HgPath;
-use crate::utils::SliceExt;
+use crate::utils::strings::SliceExt;
 use crate::vfs::VfsImpl;
 use crate::{Graph, GraphError, Revision, UncheckedRevision};
 
--- a/rust/hg-core/src/sparse.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/sparse.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -17,7 +17,7 @@
     operations::cat,
     repo::Repo,
     requirements::SPARSE_REQUIREMENT,
-    utils::{hg_path::HgPath, SliceExt},
+    utils::{hg_path::HgPath, strings::SliceExt},
     Revision, NULL_REVISION,
 };
 
--- a/rust/hg-core/src/utils.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/utils.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -8,15 +8,11 @@
 //! Contains useful functions, traits, structs, etc. for use in core.
 
 use crate::errors::{HgError, IoErrorContext};
-use crate::utils::hg_path::HgPath;
 use im_rc::ordmap::DiffItem;
 use im_rc::ordmap::OrdMap;
 use itertools::EitherOrBoth;
 use itertools::Itertools;
-use std::cell::Cell;
 use std::cmp::Ordering;
-use std::fmt;
-use std::{io::Write, ops::Deref};
 
 pub mod debug;
 pub mod files;
@@ -24,208 +20,6 @@
 pub mod path_auditor;
 pub mod strings;
 
-/// Useful until rust/issues/56345 is stable
-///
-/// # Examples
-///
-/// ```
-/// use crate::hg::utils::find_slice_in_slice;
-///
-/// let haystack = b"This is the haystack".to_vec();
-/// assert_eq!(find_slice_in_slice(&haystack, b"the"), Some(8));
-/// assert_eq!(find_slice_in_slice(&haystack, b"not here"), None);
-/// ```
-pub fn find_slice_in_slice<T>(slice: &[T], needle: &[T]) -> Option<usize>
-where
-    for<'a> &'a [T]: PartialEq,
-{
-    slice
-        .windows(needle.len())
-        .position(|window| window == needle)
-}
-
-/// Replaces the `from` slice with the `to` slice inside the `buf` slice.
-///
-/// # Examples
-///
-/// ```
-/// use crate::hg::utils::replace_slice;
-/// let mut line = b"I hate writing tests!".to_vec();
-/// replace_slice(&mut line, b"hate", b"love");
-/// assert_eq!(
-///     line,
-///     b"I love writing tests!".to_vec()
-/// );
-/// ```
-pub fn replace_slice<T>(buf: &mut [T], from: &[T], to: &[T])
-where
-    T: Clone + PartialEq,
-{
-    if buf.len() < from.len() || from.len() != to.len() {
-        return;
-    }
-    for i in 0..=buf.len() - from.len() {
-        if buf[i..].starts_with(from) {
-            buf[i..(i + from.len())].clone_from_slice(to);
-        }
-    }
-}
-
-pub trait SliceExt {
-    fn trim_end(&self) -> &Self;
-    fn trim_start(&self) -> &Self;
-    fn trim_end_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
-    fn trim_start_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
-    fn trim(&self) -> &Self;
-    fn drop_prefix(&self, needle: &Self) -> Option<&Self>;
-    fn split_2(&self, separator: u8) -> Option<(&[u8], &[u8])>;
-    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])>;
-}
-
-impl SliceExt for [u8] {
-    fn trim_end(&self) -> &[u8] {
-        self.trim_end_matches(|byte| byte.is_ascii_whitespace())
-    }
-
-    fn trim_start(&self) -> &[u8] {
-        self.trim_start_matches(|byte| byte.is_ascii_whitespace())
-    }
-
-    fn trim_end_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
-        if let Some(last) = self.iter().rposition(|&byte| !f(byte)) {
-            &self[..=last]
-        } else {
-            &[]
-        }
-    }
-
-    fn trim_start_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
-        if let Some(first) = self.iter().position(|&byte| !f(byte)) {
-            &self[first..]
-        } else {
-            &[]
-        }
-    }
-
-    /// ```
-    /// use hg::utils::SliceExt;
-    /// assert_eq!(
-    ///     b"  to trim  ".trim(),
-    ///     b"to trim"
-    /// );
-    /// assert_eq!(
-    ///     b"to trim  ".trim(),
-    ///     b"to trim"
-    /// );
-    /// assert_eq!(
-    ///     b"  to trim".trim(),
-    ///     b"to trim"
-    /// );
-    /// ```
-    fn trim(&self) -> &[u8] {
-        self.trim_start().trim_end()
-    }
-
-    fn drop_prefix(&self, needle: &Self) -> Option<&Self> {
-        if self.starts_with(needle) {
-            Some(&self[needle.len()..])
-        } else {
-            None
-        }
-    }
-
-    fn split_2(&self, separator: u8) -> Option<(&[u8], &[u8])> {
-        let pos = memchr::memchr(separator, self)?;
-        Some((&self[..pos], &self[pos + 1..]))
-    }
-
-    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])> {
-        find_slice_in_slice(self, separator)
-            .map(|pos| (&self[..pos], &self[pos + separator.len()..]))
-    }
-}
-
-pub trait Escaped {
-    /// Return bytes escaped for display to the user
-    fn escaped_bytes(&self) -> Vec<u8>;
-}
-
-impl Escaped for u8 {
-    fn escaped_bytes(&self) -> Vec<u8> {
-        let mut acc = vec![];
-        match self {
-            c @ b'\'' | c @ b'\\' => {
-                acc.push(b'\\');
-                acc.push(*c);
-            }
-            b'\t' => {
-                acc.extend(br"\\t");
-            }
-            b'\n' => {
-                acc.extend(br"\\n");
-            }
-            b'\r' => {
-                acc.extend(br"\\r");
-            }
-            c if (*c < b' ' || *c >= 127) => {
-                write!(acc, "\\x{:x}", self).unwrap();
-            }
-            c => {
-                acc.push(*c);
-            }
-        }
-        acc
-    }
-}
-
-impl<'a, T: Escaped> Escaped for &'a [T] {
-    fn escaped_bytes(&self) -> Vec<u8> {
-        self.iter().flat_map(Escaped::escaped_bytes).collect()
-    }
-}
-
-impl<T: Escaped> Escaped for Vec<T> {
-    fn escaped_bytes(&self) -> Vec<u8> {
-        self.deref().escaped_bytes()
-    }
-}
-
-impl<'a> Escaped for &'a HgPath {
-    fn escaped_bytes(&self) -> Vec<u8> {
-        self.as_bytes().escaped_bytes()
-    }
-}
-
-#[cfg(unix)]
-pub fn shell_quote(value: &[u8]) -> Vec<u8> {
-    if value.iter().all(|&byte| {
-        matches!(
-            byte,
-            b'a'..=b'z'
-            | b'A'..=b'Z'
-            | b'0'..=b'9'
-            | b'.'
-            | b'_'
-            | b'/'
-            | b'+'
-            | b'-'
-        )
-    }) {
-        value.to_owned()
-    } else {
-        let mut quoted = Vec::with_capacity(value.len() + 2);
-        quoted.push(b'\'');
-        for &byte in value {
-            if byte == b'\'' {
-                quoted.push(b'\\');
-            }
-            quoted.push(byte);
-        }
-        quoted.push(b'\'');
-        quoted
-    }
-}
-
 pub fn current_dir() -> Result<std::path::PathBuf, HgError> {
     std::env::current_dir().map_err(|error| HgError::IoError {
         error,
@@ -240,59 +34,6 @@
     })
 }
 
-/// Expand `$FOO` and `${FOO}` environment variables in the given byte string
-pub fn expand_vars(s: &[u8]) -> std::borrow::Cow<[u8]> {
-    lazy_static::lazy_static! {
-        /// https://github.com/python/cpython/blob/3.9/Lib/posixpath.py#L301
-        /// The `x` makes whitespace ignored.
-        /// `-u` disables the Unicode flag, which makes `\w` like Python with the ASCII flag.
-        static ref VAR_RE: regex::bytes::Regex =
-            regex::bytes::Regex::new(r"(?x-u)
-                \$
-                (?:
-                    (\w+)
-                    |
-                    \{
-                        ([^}]*)
-                    \}
-                )
-            ").unwrap();
-    }
-    VAR_RE.replace_all(s, |captures: &regex::bytes::Captures| {
-        let var_name = files::get_os_str_from_bytes(
-            captures
-                .get(1)
-                .or_else(|| captures.get(2))
-                .expect("either side of `|` must participate in match")
-                .as_bytes(),
-        );
-        std::env::var_os(var_name)
-            .map(files::get_bytes_from_os_str)
-            .unwrap_or_else(|| {
-                // Referencing an environment variable that does not exist.
-                // Leave the $FOO reference as-is.
-                captures[0].to_owned()
-            })
-    })
-}
-
-#[test]
-fn test_expand_vars() {
-    // Modifying process-global state in a test isn’t great,
-    // but hopefully this won’t collide with anything.
-    std::env::set_var("TEST_EXPAND_VAR", "1");
-    assert_eq!(
-        expand_vars(b"before/$TEST_EXPAND_VAR/after"),
-        &b"before/1/after"[..]
-    );
-    assert_eq!(
-        expand_vars(b"before${TEST_EXPAND_VAR}${TEST_EXPAND_VAR}${TEST_EXPAND_VAR}after"),
-        &b"before111after"[..]
-    );
-    let s = b"before $SOME_LONG_NAME_THAT_WE_ASSUME_IS_NOT_AN_ACTUAL_ENV_VAR after";
-    assert_eq!(expand_vars(s), &s[..]);
-}
-
 pub(crate) enum MergeResult<V> {
     Left,
     Right,
@@ -441,46 +182,6 @@
     }
 }
 
-/// Join items of the iterable with the given separator, similar to Python’s
-/// `separator.join(iter)`.
-///
-/// Formatting the return value consumes the iterator.
-/// Formatting it again will produce an empty string.
-pub fn join_display(
-    iter: impl IntoIterator<Item = impl fmt::Display>,
-    separator: impl fmt::Display,
-) -> impl fmt::Display {
-    JoinDisplay {
-        iter: Cell::new(Some(iter.into_iter())),
-        separator,
-    }
-}
-
-struct JoinDisplay<I, S> {
-    iter: Cell<Option<I>>,
-    separator: S,
-}
-
-impl<I, T, S> fmt::Display for JoinDisplay<I, S>
-where
-    I: Iterator<Item = T>,
-    T: fmt::Display,
-    S: fmt::Display,
-{
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        if let Some(mut iter) = self.iter.take() {
-            if let Some(first) = iter.next() {
-                first.fmt(f)?;
-            }
-            for value in iter {
-                self.separator.fmt(f)?;
-                value.fmt(f)?;
-            }
-        }
-        Ok(())
-    }
-}
-
 /// Like `Iterator::filter_map`, but over a fallible iterator of `Result`s.
 ///
 /// The callback is only called for incoming `Ok` values. Errors are passed
--- a/rust/hg-core/src/utils/files.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/utils/files.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -12,7 +12,7 @@
 use crate::utils::{
     hg_path::{path_to_hg_path_buf, HgPath, HgPathBuf, HgPathError},
     path_auditor::PathAuditor,
-    replace_slice,
+    strings::replace_slice,
 };
 use lazy_static::lazy_static;
 use same_file::is_same_file;
--- a/rust/hg-core/src/utils/hg_path.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/utils/hg_path.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -6,7 +6,7 @@
 // GNU General Public License version 2 or any later version.
 
 use crate::errors::HgError;
-use crate::utils::SliceExt;
+use crate::utils::strings::SliceExt;
 use std::borrow::Borrow;
 use std::borrow::Cow;
 use std::ffi::{OsStr, OsString};
--- a/rust/hg-core/src/utils/path_auditor.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/utils/path_auditor.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -8,8 +8,8 @@
 
 use crate::utils::{
     files::lower_clean,
-    find_slice_in_slice,
     hg_path::{hg_path_to_path_buf, HgPath, HgPathBuf, HgPathError},
+    strings::find_slice_in_slice,
 };
 use std::collections::HashSet;
 use std::path::{Path, PathBuf};
--- a/rust/hg-core/src/utils/strings.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/hg-core/src/utils/strings.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -1,3 +1,286 @@
+//! Contains string-related utilities.
+
+use crate::utils::hg_path::HgPath;
+use std::{cell::Cell, fmt, io::Write as _, ops::Deref as _};
+
+/// Useful until rust/issues/56345 is stable
+///
+/// # Examples
+///
+/// ```
+/// use hg::utils::strings::find_slice_in_slice;
+///
+/// let haystack = b"This is the haystack".to_vec();
+/// assert_eq!(find_slice_in_slice(&haystack, b"the"), Some(8));
+/// assert_eq!(find_slice_in_slice(&haystack, b"not here"), None);
+/// ```
+pub fn find_slice_in_slice<T>(slice: &[T], needle: &[T]) -> Option<usize>
+where
+    for<'a> &'a [T]: PartialEq,
+{
+    slice
+        .windows(needle.len())
+        .position(|window| window == needle)
+}
+
+/// Replaces the `from` slice with the `to` slice inside the `buf` slice.
+///
+/// # Examples
+///
+/// ```
+/// use hg::utils::strings::replace_slice;
+/// let mut line = b"I hate writing tests!".to_vec();
+/// replace_slice(&mut line, b"hate", b"love");
+/// assert_eq!(
+///     line,
+///     b"I love writing tests!".to_vec()
+/// );
+/// ```
+pub fn replace_slice<T>(buf: &mut [T], from: &[T], to: &[T])
+where
+    T: Clone + PartialEq,
+{
+    if buf.len() < from.len() || from.len() != to.len() {
+        return;
+    }
+    for i in 0..=buf.len() - from.len() {
+        if buf[i..].starts_with(from) {
+            buf[i..(i + from.len())].clone_from_slice(to);
+        }
+    }
+}
+
+pub trait SliceExt {
+    fn trim_end(&self) -> &Self;
+    fn trim_start(&self) -> &Self;
+    fn trim_end_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
+    fn trim_start_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
+    fn trim(&self) -> &Self;
+    fn drop_prefix(&self, needle: &Self) -> Option<&Self>;
+    fn split_2(&self, separator: u8) -> Option<(&[u8], &[u8])>;
+    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])>;
+}
+
+impl SliceExt for [u8] {
+    fn trim_end(&self) -> &[u8] {
+        self.trim_end_matches(|byte| byte.is_ascii_whitespace())
+    }
+
+    fn trim_start(&self) -> &[u8] {
+        self.trim_start_matches(|byte| byte.is_ascii_whitespace())
+    }
+
+    fn trim_end_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
+        if let Some(last) = self.iter().rposition(|&byte| !f(byte)) {
+            &self[..=last]
+        } else {
+            &[]
+        }
+    }
+
+    fn trim_start_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
+        if let Some(first) = self.iter().position(|&byte| !f(byte)) {
+            &self[first..]
+        } else {
+            &[]
+        }
+    }
+
+    /// ```
+    /// use hg::utils::strings::SliceExt;
+    /// assert_eq!(
+    ///     b"  to trim  ".trim(),
+    ///     b"to trim"
+    /// );
+    /// assert_eq!(
+    ///     b"to trim  ".trim(),
+    ///     b"to trim"
+    /// );
+    /// assert_eq!(
+    ///     b"  to trim".trim(),
+    ///     b"to trim"
+    /// );
+    /// ```
+    fn trim(&self) -> &[u8] {
+        self.trim_start().trim_end()
+    }
+
+    fn drop_prefix(&self, needle: &Self) -> Option<&Self> {
+        if self.starts_with(needle) {
+            Some(&self[needle.len()..])
+        } else {
+            None
+        }
+    }
+
+    fn split_2(&self, separator: u8) -> Option<(&[u8], &[u8])> {
+        let pos = memchr::memchr(separator, self)?;
+        Some((&self[..pos], &self[pos + 1..]))
+    }
+
+    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])> {
+        find_slice_in_slice(self, separator)
+            .map(|pos| (&self[..pos], &self[pos + separator.len()..]))
+    }
+}
+
+pub trait Escaped {
+    /// Return bytes escaped for display to the user
+    fn escaped_bytes(&self) -> Vec<u8>;
+}
+
+impl Escaped for u8 {
+    fn escaped_bytes(&self) -> Vec<u8> {
+        let mut acc = vec![];
+        match self {
+            c @ b'\'' | c @ b'\\' => {
+                acc.push(b'\\');
+                acc.push(*c);
+            }
+            b'\t' => {
+                acc.extend(br"\\t");
+            }
+            b'\n' => {
+                acc.extend(br"\\n");
+            }
+            b'\r' => {
+                acc.extend(br"\\r");
+            }
+            c if (*c < b' ' || *c >= 127) => {
+                write!(acc, "\\x{:x}", self).unwrap();
+            }
+            c => {
+                acc.push(*c);
+            }
+        }
+        acc
+    }
+}
+
+impl<'a, T: Escaped> Escaped for &'a [T] {
+    fn escaped_bytes(&self) -> Vec<u8> {
+        self.iter().flat_map(Escaped::escaped_bytes).collect()
+    }
+}
+
+impl<T: Escaped> Escaped for Vec<T> {
+    fn escaped_bytes(&self) -> Vec<u8> {
+        self.deref().escaped_bytes()
+    }
+}
+
+impl<'a> Escaped for &'a HgPath {
+    fn escaped_bytes(&self) -> Vec<u8> {
+        self.as_bytes().escaped_bytes()
+    }
+}
+
+#[cfg(unix)]
+pub fn shell_quote(value: &[u8]) -> Vec<u8> {
+    if value.iter().all(|&byte| {
+        matches!(
+            byte,
+            b'a'..=b'z'
+            | b'A'..=b'Z'
+            | b'0'..=b'9'
+            | b'.'
+            | b'_'
+            | b'/'
+            | b'+'
+            | b'-'
+        )
+    }) {
+        value.to_owned()
+    } else {
+        let mut quoted = Vec::with_capacity(value.len() + 2);
+        quoted.push(b'\'');
+        for &byte in value {
+            if byte == b'\'' {
+                quoted.push(b'\\');
+            }
+            quoted.push(byte);
+        }
+        quoted.push(b'\'');
+        quoted
+    }
+}
+
+/// Expand `$FOO` and `${FOO}` environment variables in the given byte string
+pub fn expand_vars(s: &[u8]) -> std::borrow::Cow<[u8]> {
+    lazy_static::lazy_static! {
+        /// https://github.com/python/cpython/blob/3.9/Lib/posixpath.py#L301
+        /// The `x` makes whitespace ignored.
+        /// `-u` disables the Unicode flag, which makes `\w` like Python with the ASCII flag.
+        static ref VAR_RE: regex::bytes::Regex =
+            regex::bytes::Regex::new(r"(?x-u)
+                \$
+                (?:
+                    (\w+)
+                    |
+                    \{
+                        ([^}]*)
+                    \}
+                )
+            ").unwrap();
+    }
+    VAR_RE.replace_all(s, |captures: &regex::bytes::Captures| {
+        let var_name = crate::utils::files::get_os_str_from_bytes(
+            captures
+                .get(1)
+                .or_else(|| captures.get(2))
+                .expect("either side of `|` must participate in match")
+                .as_bytes(),
+        );
+        std::env::var_os(var_name)
+            .map(crate::utils::files::get_bytes_from_os_str)
+            .unwrap_or_else(|| {
+                // Referencing an environment variable that does not exist.
+                // Leave the $FOO reference as-is.
+                captures[0].to_owned()
+            })
+    })
+}
+
+/// Join items of the iterable with the given separator, similar to Python’s
+/// `separator.join(iter)`.
+///
+/// Formatting the return value consumes the iterator.
+/// Formatting it again will produce an empty string.
+pub fn join_display(
+    iter: impl IntoIterator<Item = impl fmt::Display>,
+    separator: impl fmt::Display,
+) -> impl fmt::Display {
+    JoinDisplay {
+        iter: Cell::new(Some(iter.into_iter())),
+        separator,
+    }
+}
+
+struct JoinDisplay<I, S> {
+    iter: Cell<Option<I>>,
+    separator: S,
+}
+
+impl<I, T, S> fmt::Display for JoinDisplay<I, S>
+where
+    I: Iterator<Item = T>,
+    T: fmt::Display,
+    S: fmt::Display,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if let Some(mut iter) = self.iter.take() {
+            if let Some(first) = iter.next() {
+                first.fmt(f)?;
+            }
+            for value in iter {
+                self.separator.fmt(f)?;
+                value.fmt(f)?;
+            }
+        }
+        Ok(())
+    }
+}
+
 /// Returns a short representation of a user name or email address.
 pub fn short_user(user: &[u8]) -> &[u8] {
     let mut str = user;
@@ -21,6 +304,23 @@
     use super::*;
 
     #[test]
+    fn test_expand_vars() {
+        // Modifying process-global state in a test isn’t great,
+        // but hopefully this won’t collide with anything.
+        std::env::set_var("TEST_EXPAND_VAR", "1");
+        assert_eq!(
+            expand_vars(b"before/$TEST_EXPAND_VAR/after"),
+            &b"before/1/after"[..]
+        );
+        assert_eq!(
+        expand_vars(b"before${TEST_EXPAND_VAR}${TEST_EXPAND_VAR}${TEST_EXPAND_VAR}after"),
+        &b"before111after"[..]
+    );
+        let s = b"before $SOME_LONG_NAME_THAT_WE_ASSUME_IS_NOT_AN_ACTUAL_ENV_VAR after";
+        assert_eq!(expand_vars(s), &s[..]);
+    }
+
+    #[test]
     fn test_short_user() {
         assert_eq!(short_user(b""), b"");
         assert_eq!(short_user(b"Name"), b"Name");
--- a/rust/rhg/src/blackbox.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/rhg/src/blackbox.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -4,7 +4,7 @@
 use format_bytes::format_bytes;
 use hg::errors::HgError;
 use hg::repo::Repo;
-use hg::utils::{files::get_bytes_from_os_str, shell_quote};
+use hg::utils::{files::get_bytes_from_os_str, strings::shell_quote};
 use std::ffi::OsString;
 
 // Python does not support %.3f, only %f
--- a/rust/rhg/src/commands/config.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/rhg/src/commands/config.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -2,7 +2,7 @@
 use clap::Arg;
 use format_bytes::format_bytes;
 use hg::errors::HgError;
-use hg::utils::SliceExt;
+use hg::utils::strings::SliceExt;
 
 pub const HELP_TEXT: &str = "
 With one argument of the form section.name, print just the value of that config item.
--- a/rust/rhg/src/main.rs	Fri Jan 03 10:50:17 2025 -0500
+++ b/rust/rhg/src/main.rs	Thu Jan 16 13:15:02 2025 -0500
@@ -6,7 +6,7 @@
 use hg::config::{Config, ConfigSource, PlainInfo};
 use hg::repo::{Repo, RepoError};
 use hg::utils::files::{get_bytes_from_os_str, get_path_from_bytes};
-use hg::utils::SliceExt;
+use hg::utils::strings::SliceExt;
 use hg::{exit_codes, requirements};
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};