use super::workspace::*;
use regex::Regex;
use semver::{Version, VersionReq};
use serde::Deserialize;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::LazyLock;
use thiserror::Error;
static PROTOCOL: LazyLock<Regex> = LazyLock::new(|| Regex::new("^(?<protocol>[a-z+]+):").unwrap());
static GITHUB: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
"(?<owner>[A-Za-z0-9_.-]+)/(?<repo>[A-Za-z0-9_.-]+)(?:#(?<commit>[A-Za-z0-9_.-/]+))?",
)
.unwrap()
});
fn clean_version(version: &str) -> String {
let mut value = version
.trim()
.replace(".*", "")
.replace(".x", "")
.replace(".X", "")
.replace("-*", "");
if value.contains(" ") && !value.contains(",") {
value = value.replace(" ", ", ");
}
let count = value.chars().filter(|c| *c == '.').count();
if (value != version || count < 2) && !value.starts_with(['^', '~', '>', '<', '=']) {
return format!("~{value}");
}
value
}
#[derive(Debug, Error)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
pub enum VersionProtocolError {
#[error("Missing start version for range.")]
#[cfg_attr(
feature = "miette",
diagnostic(code(package_json::version::missing_range_start))
)]
RangeMissingStartVersion,
#[error("Missing stop version for range.")]
#[cfg_attr(
feature = "miette",
diagnostic(code(package_json::version::missing_range_stop))
)]
RangeMissingStopVersion,
#[error("Failed to parse version or requirement: {0}")]
#[cfg_attr(feature = "miette", diagnostic(code(package_json::version::invalid)))]
Semver(#[from] semver::Error),
#[error(transparent)]
#[cfg_attr(feature = "miette", diagnostic(transparent))]
Workspace(#[from] WorkspaceProtocolError),
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
#[serde(untagged, try_from = "String", into = "String")]
pub enum VersionProtocol {
Alias(String),
Catalog(Option<String>),
Exec(PathBuf),
File(PathBuf),
Git {
reference: Option<String>,
url: String,
},
GitHub {
reference: Option<String>,
owner: String,
repo: String,
},
Link(PathBuf),
Patch(String),
Portal(PathBuf),
Range(Vec<VersionReq>),
Requirement(VersionReq),
Tag(String),
Url(String),
Version(Version),
Workspace(WorkspaceProtocol),
Unknown(String),
}
impl VersionProtocol {
pub fn parse(value: impl AsRef<str>) -> Result<Self, VersionProtocolError> {
Self::from_str(value.as_ref())
}
}
impl FromStr for VersionProtocol {
type Err = VersionProtocolError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() || value == "*" {
return Ok(VersionProtocol::Requirement(VersionReq::parse("*")?));
}
let value = value.trim();
if let Some(caps) = PROTOCOL.captures(value) {
let protocol = caps.name("protocol").unwrap().as_str();
let index = protocol.len();
match protocol {
"catalog" => {
return Ok(VersionProtocol::Catalog(if index == value.len() - 1 {
None
} else {
Some(String::from(&value[index + 1..]))
}));
}
"http" | "https" => {
return Ok(VersionProtocol::Url(value.to_owned()));
}
"git" | "git+ssh" | "git+http" | "git+https" | "git+file" => {
let mut parts = value.split('#');
return Ok(VersionProtocol::Git {
url: parts.next().unwrap().to_owned(),
reference: parts.next().map(|p| p.to_owned()),
});
}
"exec" => {
return Ok(VersionProtocol::Exec(PathBuf::from(&value[index + 1..])));
}
"file" => {
return Ok(VersionProtocol::File(PathBuf::from(&value[index + 1..])));
}
"link" => {
return Ok(VersionProtocol::Link(PathBuf::from(&value[index + 1..])));
}
"patch" => {
return Ok(VersionProtocol::Patch(String::from(&value[index + 1..])));
}
"portal" => {
return Ok(VersionProtocol::Portal(PathBuf::from(&value[index + 1..])));
}
"workspace" => {
return Ok(VersionProtocol::Workspace(WorkspaceProtocol::parse(
&value[index + 1..],
)?));
}
"jsr" | "npm" => {
return Ok(VersionProtocol::Alias(value.to_owned()));
}
_ => {}
}
}
if let Some(caps) = GITHUB.captures(value) {
return Ok(VersionProtocol::GitHub {
owner: caps.name("owner").unwrap().as_str().to_owned(),
repo: caps.name("repo").unwrap().as_str().to_owned(),
reference: caps.name("commit").map(|c| c.as_str().to_owned()),
});
}
if value.contains(" - ") {
let mut parts = value.split(" - ");
let l = parts
.next()
.ok_or(VersionProtocolError::RangeMissingStartVersion)?
.trim();
let r = parts
.next()
.ok_or(VersionProtocolError::RangeMissingStopVersion)?
.trim();
return Ok(VersionProtocol::Requirement(VersionReq::parse(&format!(
">={l}, <={r}",
))?));
}
if value.contains("||") {
let mut ranges = vec![];
for range in value.split("||") {
ranges.push(VersionReq::parse(&clean_version(range))?);
}
return Ok(VersionProtocol::Range(ranges));
}
if value.chars().next().is_some_and(|ch| ch.is_alphabetic()) {
return Ok(VersionProtocol::Tag(value.to_owned()));
}
let value = clean_version(value);
if value.contains('^')
|| value.contains('~')
|| value.contains('>')
|| value.contains('<')
|| value.contains('=')
{
return Ok(VersionProtocol::Requirement(VersionReq::parse(&value)?));
}
if let Ok(version) = Version::parse(&value) {
return Ok(VersionProtocol::Version(version));
}
Ok(VersionProtocol::Unknown(value))
}
}
impl TryFrom<String> for VersionProtocol {
type Error = VersionProtocolError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}
impl From<VersionProtocol> for String {
fn from(value: VersionProtocol) -> String {
value.to_string()
}
}
impl fmt::Display for VersionProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
VersionProtocol::Alias(value) => value.to_owned(),
VersionProtocol::Catalog(value) =>
format!("catalog:{}", value.as_deref().unwrap_or_default()),
VersionProtocol::Exec(path) => format!("exec:{}", path.display()),
VersionProtocol::File(path) => format!("file:{}", path.display()),
VersionProtocol::Git { reference, url } => reference
.as_ref()
.map(|c| format!("{url}#{c}"))
.unwrap_or_else(|| url.to_owned()),
VersionProtocol::GitHub {
reference,
owner,
repo,
} => {
let github = format!("{owner}/{repo}");
reference
.as_ref()
.map(|c| format!("{github}#{c}"))
.unwrap_or_else(|| github)
}
VersionProtocol::Link(path) => format!("link:{}", path.display()),
VersionProtocol::Patch(patch) => format!("patch:{patch}"),
VersionProtocol::Portal(path) => format!("portal:{}", path.display()),
VersionProtocol::Range(range) => range
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(" || "),
VersionProtocol::Requirement(req) => req.to_string(),
VersionProtocol::Tag(tag) => tag.to_owned(),
VersionProtocol::Url(url) => url.to_owned(),
VersionProtocol::Version(ver) => ver.to_string(),
VersionProtocol::Workspace(ws) => format!("workspace:{ws}"),
VersionProtocol::Unknown(value) => value.to_owned(),
}
)
}
}