diff --git a/Cargo.lock b/Cargo.lock index a078b2370..e51513afe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3258,6 +3258,7 @@ dependencies = [ "clap_complete_nushell", "crossterm", "md-5", + "regex", "serde_json", "tokio", "toml_edit", diff --git a/yazi-cli/Cargo.toml b/yazi-cli/Cargo.toml index cc068dd98..280142905 100644 --- a/yazi-cli/Cargo.toml +++ b/yazi-cli/Cargo.toml @@ -18,6 +18,7 @@ anyhow = { workspace = true } clap = { workspace = true } crossterm = { workspace = true } md-5 = { workspace = true } +regex = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } toml_edit = "0.22.22" diff --git a/yazi-cli/src/package/deploy.rs b/yazi-cli/src/package/deploy.rs index 6bdc10564..a3b71103f 100644 --- a/yazi-cli/src/package/deploy.rs +++ b/yazi-cli/src/package/deploy.rs @@ -8,7 +8,7 @@ const TRACKER: &str = "DO_NOT_MODIFY_ANYTHING_IN_THIS_DIRECTORY"; impl Package { pub(super) async fn deploy(&mut self) -> Result<()> { - let Some(name) = self.name().map(ToOwned::to_owned) else { bail!("Invalid package url") }; + let name = self.name().to_owned(); let from = self.local().join(&self.child); self.header("Deploying package `{name}`")?; diff --git a/yazi-cli/src/package/package.rs b/yazi-cli/src/package/package.rs index 5a8034cf6..28974a88e 100644 --- a/yazi-cli/src/package/package.rs +++ b/yazi-cli/src/package/package.rs @@ -1,49 +1,99 @@ -use std::{borrow::Cow, io::BufWriter, path::PathBuf}; +use std::{borrow::Cow, io::BufWriter, path::PathBuf, sync::OnceLock}; -use anyhow::Result; +use anyhow::{Error, Result}; use md5::{Digest, Md5}; +use regex::Regex; use yazi_shared::Xdg; +static PACKAGE_RAW_URL_RE: OnceLock = OnceLock::new(); +static PACKAGE_SHORT_URL_RE: OnceLock = OnceLock::new(); + +#[inline] +fn package_raw_url_re() -> &'static Regex { + PACKAGE_RAW_URL_RE.get_or_init(|| { + Regex::new(r"^(?P[^:]+)://(?P[^/]+)/(?P[^:]+)(:(?P.*))?$") + .unwrap() + }) +} + +#[inline] +fn package_short_url_re() -> &'static Regex { + PACKAGE_SHORT_URL_RE.get_or_init(|| { + Regex::new(r"^((?P[^/]+)/)?(?P[^/]+)/(?P[^:]+)(:(?P.*))?$").unwrap() + }) +} + +#[derive(Debug)] pub(crate) struct Package { - pub(crate) repo: String, + pub(crate) proto: String, + pub(crate) host: String, + pub(crate) url_path: String, pub(crate) child: String, pub(crate) rev: String, pub(super) is_flavor: bool, } impl Package { - pub(super) fn new(url: &str, rev: Option<&str>) -> Self { - let mut parts = url.splitn(2, ':'); + pub(super) fn new(url: &str, rev: Option<&str>) -> Result { + let rev = rev.unwrap_or_default().to_owned(); + let is_flavor = false; - let mut repo = parts.next().unwrap_or_default().to_owned(); - let child = if let Some(s) = parts.next() { - format!("{s}.yazi") - } else { - repo.push_str(".yazi"); - String::new() - }; + if let Some(raw_url_match) = package_raw_url_re().captures(url) { + let proto = raw_url_match["proto"].to_owned(); + let host = raw_url_match["host"].to_owned(); + let mut url_path = raw_url_match["url_path"].to_owned(); + let child = if let Some(child) = raw_url_match.name("child") { + format!("{}.yazi", child.as_str()) + } else { + url_path.push_str(".yazi"); + String::new() + }; + + Ok(Self { proto, host, url_path, child, rev, is_flavor }) + } else if let Some(short_url_match) = package_short_url_re().captures(url) { + let proto = "https".to_owned(); + let host = + short_url_match.name("host").map(|m| m.as_str()).unwrap_or("github.com").to_owned(); + let owner = &short_url_match["owner"]; + let repo = &short_url_match["repo"]; + let mut url_path = format!("{owner}/{repo}"); + + let child = if let Some(child) = short_url_match.name("child") { + format!("{}.yazi", child.as_str()) + } else { + url_path.push_str(".yazi"); + String::new() + }; - Self { repo, child, rev: rev.unwrap_or_default().to_owned(), is_flavor: false } + Ok(Self { proto, host, url_path, child, rev, is_flavor }) + } else { + Err(Error::msg("invalid package url")) + } } #[inline] pub(super) fn use_(&self) -> Cow { if self.child.is_empty() { - self.repo.trim_end_matches(".yazi").into() + format!("{}://{}/{}", self.proto, self.host, self.url_path.trim_end_matches(".yazi")).into() } else { - format!("{}:{}", self.repo, self.child.trim_end_matches(".yazi")).into() + format!( + "{}://{}/{}:{}", + self.proto, + self.host, + self.url_path, + self.child.trim_end_matches(".yazi") + ) + .into() } } #[inline] - pub(super) fn name(&self) -> Option<&str> { - let s = if self.child.is_empty() { - self.repo.split('/').last().filter(|s| !s.is_empty()) + pub(super) fn name(&self) -> &str { + if self.child.is_empty() { + self.url_path.rsplit('/').next().unwrap_or(&self.url_path) } else { - Some(self.child.as_str()) - }; - - s.filter(|s| s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-' | b'.'))) + self.child.as_str() + } } #[inline] @@ -55,8 +105,7 @@ impl Package { #[inline] pub(super) fn remote(&self) -> String { - // Support more Git hosting services in the future - format!("https://github.com/{}.git", self.repo) + format!("{}://{}/{}", self.proto, self.host, self.url_path) } pub(super) fn header(&self, s: &str) -> Result<()> { @@ -68,7 +117,7 @@ impl Package { SetAttributes(Attribute::Reverse.into()), SetAttributes(Attribute::Bold.into()), Print(" "), - Print(s.replacen("{name}", self.name().unwrap_or_default(), 1)), + Print(s.replacen("{name}", self.name(), 1)), Print(" "), SetAttributes(Attribute::Reset.into()), Print("\n\n"), @@ -76,3 +125,116 @@ impl Package { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_component_short_url() -> Result<()> { + let url = "owner/repo"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "https"); + assert_eq!(pkg.host, "github.com"); + assert_eq!(pkg.url_path, "owner/repo.yazi"); + assert_eq!(pkg.child, ""); + + assert_eq!(pkg.remote(), "https://github.com/owner/repo.yazi"); + + Ok(()) + } + + #[test] + fn three_component_short_url() -> Result<()> { + let url = "codeberg.org/owner/repo"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "https"); + assert_eq!(pkg.host, "codeberg.org"); + assert_eq!(pkg.url_path, "owner/repo.yazi"); + assert_eq!(pkg.child, ""); + + assert_eq!(pkg.remote(), "https://codeberg.org/owner/repo.yazi"); + + Ok(()) + } + + #[test] + fn two_component_short_url_with_child_path() -> Result<()> { + let url = "owner/repo:my-plugin"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "https"); + assert_eq!(pkg.host, "github.com"); + assert_eq!(pkg.url_path, "owner/repo"); + assert_eq!(pkg.child, "my-plugin.yazi"); + + assert_eq!(pkg.remote(), "https://github.com/owner/repo"); + assert_eq!(pkg.use_(), "https://github.com/owner/repo:my-plugin"); + + Ok(()) + } + + #[test] + fn raw_ssh_url() -> Result<()> { + let url = "ssh://git@my-host:6969/my-plugin"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "ssh"); + assert_eq!(pkg.host, "git@my-host:6969"); + assert_eq!(pkg.url_path, "my-plugin.yazi"); + assert_eq!(pkg.child, ""); + + assert_eq!(pkg.remote(), "ssh://git@my-host:6969/my-plugin.yazi"); + + Ok(()) + } + + #[test] + fn raw_ssh_url_with_child_path() -> Result<()> { + let url = "ssh://git@192.168.0.69:2222/~/my-repo.git:my-plugin"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "ssh"); + assert_eq!(pkg.host, "git@192.168.0.69:2222"); + assert_eq!(pkg.url_path, "~/my-repo.git"); + assert_eq!(pkg.child, "my-plugin.yazi"); + + assert_eq!(pkg.remote(), "ssh://git@192.168.0.69:2222/~/my-repo.git"); + assert_eq!(pkg.use_(), "ssh://git@192.168.0.69:2222/~/my-repo.git:my-plugin"); + + Ok(()) + } + + #[test] + fn raw_http_url_with_non_standard_path() -> Result<()> { + let url = "https://example.com/xxx/yyy/zzz/owner/repo:my-plugin"; + + let pkg = Package::new(url, None)?; + + assert_eq!(pkg.proto, "https"); + assert_eq!(pkg.host, "example.com"); + assert_eq!(pkg.url_path, "xxx/yyy/zzz/owner/repo"); + assert_eq!(pkg.child, "my-plugin.yazi"); + + assert_eq!(pkg.remote(), "https://example.com/xxx/yyy/zzz/owner/repo"); + assert_eq!(pkg.use_(), "https://example.com/xxx/yyy/zzz/owner/repo:my-plugin"); + + Ok(()) + } + + #[test] + fn invalid_url() { + let url = "one-component-url???"; + + let pkg = Package::new(url, None); + + assert!(pkg.is_err()); + } +} diff --git a/yazi-cli/src/package/parser.rs b/yazi-cli/src/package/parser.rs index c58020e89..b65ca459c 100644 --- a/yazi-cli/src/package/parser.rs +++ b/yazi-cli/src/package/parser.rs @@ -34,8 +34,8 @@ impl Package { } pub(crate) async fn add_to_config(use_: &str) -> Result<()> { - let mut package = Self::new(use_, None); - let Some(name) = package.name() else { bail!("Invalid package `use`") }; + let mut package = Self::new(use_, None).context("Invalid package `use`")?; + let name = package.name(); let path = Xdg::config_dir().join("package.toml"); let mut doc = Self::ensure_config(&fs::read_to_string(&path).await.unwrap_or_default())?; @@ -76,7 +76,7 @@ impl Package { let use_ = dep.get("use").and_then(|d| d.as_str()).context("Missing `use` field")?; let rev = dep.get("rev").and_then(|d| d.as_str()); - let mut package = Package::new(use_, rev); + let mut package = Package::new(use_, rev)?; if upgrade { package.upgrade().await?; } else { @@ -144,10 +144,9 @@ impl Package { fn ensure_unique(doc: &DocumentMut, name: &str) -> Result<()> { #[inline] fn same(v: &Value, name: &str) -> bool { - v.as_inline_table() - .and_then(|t| t.get("use")) - .and_then(|v| v.as_str()) - .is_some_and(|s| Package::new(s, None).name() == Some(name)) + v.as_inline_table().and_then(|t| t.get("use")).and_then(|v| v.as_str()).is_some_and(|s| { + if let Ok(pkg) = Package::new(s, None) { pkg.name() == name } else { false } + }) } if doc["plugin"]["deps"].as_array().unwrap().into_iter().any(|v| same(v, name)) {