Move `NumericPrefixWithSuffix` into utils

Kirill Bulatov created

Change summary

Cargo.lock                                |  1 
Cargo.toml                                |  1 
crates/project_panel/Cargo.toml           |  2 
crates/project_panel/src/project_panel.rs | 31 -------------
crates/util/Cargo.toml                    |  1 
crates/util/src/util.rs                   | 57 +++++++++++++++++++++++++
6 files changed, 62 insertions(+), 31 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10487,6 +10487,7 @@ dependencies = [
  "take-until",
  "tempfile",
  "tendril",
+ "unicase",
  "url",
 ]
 

Cargo.toml 🔗

@@ -294,6 +294,7 @@ tree-sitter-vue = { git = "https://github.com/zed-industries/tree-sitter-vue", r
 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930" }
 tree-sitter-zig = { git = "https://github.com/maxxnino/tree-sitter-zig", rev = "0d08703e4c3f426ec61695d7617415fff97029bd" }
 unindent = "0.1.7"
+unicase = "2.6"
 url = "2.2"
 uuid = { version = "1.1.2", features = ["v4"] }
 wasmtime = "18.0"

crates/project_panel/Cargo.toml 🔗

@@ -26,7 +26,7 @@ serde_json.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true
-unicase = "2.6"
+unicase.workspace = true
 util.workspace = true
 client.workspace = true
 workspace.workspace = true

crates/project_panel/src/project_panel.rs 🔗

@@ -27,7 +27,7 @@ use std::{cmp::Ordering, ffi::OsStr, ops::Range, path::Path, sync::Arc};
 use theme::ThemeSettings;
 use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
 use unicase::UniCase;
-use util::{maybe, ResultExt, TryFutureExt};
+use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::DetachAndPromptErr,
@@ -1498,35 +1498,6 @@ impl ProjectPanel {
     }
 }
 
-#[derive(Debug, PartialEq)]
-struct NumericPrefixWithSuffix<'a>(i32, &'a str);
-
-impl<'a> NumericPrefixWithSuffix<'a> {
-    fn from_str(str: &'a str) -> Option<Self> {
-        let mut chars = str.chars();
-        let prefix: String = chars.by_ref().take_while(|c| c.is_digit(10)).collect();
-        let remainder = chars.as_str();
-
-        match prefix.parse::<i32>() {
-            Ok(prefix) => Some(NumericPrefixWithSuffix(prefix, remainder)),
-            Err(_) => None,
-        }
-    }
-}
-
-impl<'a> PartialOrd for NumericPrefixWithSuffix<'a> {
-    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
-        let NumericPrefixWithSuffix(num_a, remainder_a) = self;
-        let NumericPrefixWithSuffix(num_b, remainder_b) = other;
-
-        Some(
-            num_a
-                .cmp(&num_b)
-                .then_with(|| UniCase::new(remainder_a).cmp(&UniCase::new(remainder_b))),
-        )
-    }
-}
-
 impl Render for ProjectPanel {
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
         let has_worktree = self.visible_entries.len() != 0;

crates/util/Cargo.toml 🔗

@@ -31,6 +31,7 @@ serde_json.workspace = true
 smol.workspace = true
 take-until = "0.2.0"
 tempfile = { workspace = true, optional = true }
+unicase.workspace = true
 url.workspace = true
 
 [target.'cfg(windows)'.dependencies]

crates/util/src/util.rs 🔗

@@ -22,6 +22,7 @@ use std::{
     task::{Context, Poll},
     time::Instant,
 };
+use unicase::UniCase;
 
 pub use take_until::*;
 
@@ -487,6 +488,43 @@ impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
     }
 }
 
+/// A way to sort strings with starting numbers numerically first, falling back to alphanumeric one,
+/// case-insensitive.
+///
+/// This is useful for turning regular alphanumerically sorted sequences as `1-abc, 10, 11-def, .., 2, 21-abc`
+/// into `1-abc, 2, 10, 11-def, .., 21-abc`
+#[derive(Debug, PartialEq, Eq)]
+pub struct NumericPrefixWithSuffix<'a>(i32, &'a str);
+
+impl<'a> NumericPrefixWithSuffix<'a> {
+    pub fn from_str(str: &'a str) -> Option<Self> {
+        let mut chars = str.chars();
+        let prefix: String = chars.by_ref().take_while(|c| c.is_digit(10)).collect();
+        let remainder = chars.as_str();
+
+        match prefix.parse::<i32>() {
+            Ok(prefix) => Some(NumericPrefixWithSuffix(prefix, remainder)),
+            Err(_) => None,
+        }
+    }
+}
+
+impl Ord for NumericPrefixWithSuffix<'_> {
+    fn cmp(&self, other: &Self) -> Ordering {
+        let NumericPrefixWithSuffix(num_a, remainder_a) = self;
+        let NumericPrefixWithSuffix(num_b, remainder_b) = other;
+        num_a
+            .cmp(&num_b)
+            .then_with(|| UniCase::new(remainder_a).cmp(&UniCase::new(remainder_b)))
+    }
+}
+
+impl<'a> PartialOrd for NumericPrefixWithSuffix<'a> {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -526,4 +564,23 @@ mod tests {
         assert_eq!(truncate_and_trailoff("èèèèèè", 6), "èèèèèè");
         assert_eq!(truncate_and_trailoff("èèèèèè", 5), "èèèèè…");
     }
+
+    #[test]
+    fn test_numeric_prefix_with_suffix() {
+        let mut sorted = vec!["1-abc", "10", "11def", "2", "21-abc"];
+        sorted.sort_by_key(|s| {
+            NumericPrefixWithSuffix::from_str(s).unwrap_or_else(|| {
+                panic!("Cannot convert string `{s}` into NumericPrefixWithSuffix")
+            })
+        });
+        assert_eq!(sorted, ["1-abc", "2", "10", "11def", "21-abc"]);
+
+        for numeric_prefix_less in ["numeric_prefix_less", "aaa", "~™£"] {
+            assert_eq!(
+                NumericPrefixWithSuffix::from_str(numeric_prefix_less),
+                None,
+                "String without numeric prefix `{numeric_prefix_less}` should not be converted into NumericPrefixWithSuffix"
+            )
+        }
+    }
 }