1use semver::{Version, VersionReq};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Spec {
15 Registry(String),
18 Alias { name: String, spec: Box<Spec> },
21 Git {
24 source: String,
25 committish: Option<String>,
26 },
27 Tarball(String),
29 Path(String),
31}
32
33impl Spec {
34 pub fn parse(spec: &str) -> Spec {
37 let s = spec.trim();
38
39 if let Some(rest) = s.strip_prefix("npm:") {
40 let (name, inner) = split_alias(rest);
41 return Spec::Alias {
42 name: name.to_string(),
43 spec: Box::new(Spec::parse(inner)),
44 };
45 }
46 if is_git_url(s) {
47 return git_spec(s);
48 }
49 if s.starts_with("http://") || s.starts_with("https://") {
50 return Spec::Tarball(s.to_string());
51 }
52 if is_path(s) {
53 return Spec::Path(s.to_string());
54 }
55 if is_git_shorthand(s) {
57 return git_spec(s);
58 }
59 Spec::Registry(s.to_string())
60 }
61
62 pub fn is_registry(&self) -> bool {
64 match self {
65 Spec::Registry(_) => true,
66 Spec::Alias { spec, .. } => spec.is_registry(),
67 Spec::Git { .. } | Spec::Tarball(_) | Spec::Path(_) => false,
68 }
69 }
70}
71
72pub fn version_req(spec: &str) -> Result<VersionReq, semver::Error> {
77 let spec = spec.trim();
78 if spec.is_empty() || spec == "*" || spec == "x" || spec == "latest" {
79 return Ok(VersionReq::STAR);
80 }
81 if Version::parse(spec).is_ok() {
82 return VersionReq::parse(&format!("={spec}"));
83 }
84 VersionReq::parse(spec)
85}
86
87fn git_spec(s: &str) -> Spec {
89 match s.split_once('#') {
90 Some((source, c)) => Spec::Git {
91 source: source.to_string(),
92 committish: Some(c.to_string()),
93 },
94 None => Spec::Git {
95 source: s.to_string(),
96 committish: None,
97 },
98 }
99}
100
101fn is_git_url(s: &str) -> bool {
103 const GIT_PREFIXES: &[&str] = &[
104 "git+",
105 "git://",
106 "git@",
107 "ssh://",
108 "github:",
109 "gitlab:",
110 "bitbucket:",
111 "gist:",
112 ];
113 GIT_PREFIXES.iter().any(|p| s.starts_with(p))
114}
115
116fn is_git_shorthand(s: &str) -> bool {
120 let head = s.split('#').next().unwrap_or(s);
121 head.contains('/') && !head.starts_with('@') && !head.contains("://")
122}
123
124fn is_path(s: &str) -> bool {
127 s.starts_with("file:")
128 || s.starts_with("./")
129 || s.starts_with("../")
130 || s.starts_with('/')
131 || s.starts_with("~/")
132}
133
134fn split_alias(rest: &str) -> (&str, &str) {
137 match rest.rfind('@') {
138 Some(at) if at > 0 => (&rest[..at], &rest[at + 1..]),
139 _ => (rest, ""),
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn version_req_pins_bare_versions_and_parses_ranges() {
149 assert_eq!(version_req("1.2.3").unwrap(), "=1.2.3".parse().unwrap());
150 assert_eq!(version_req("^3.0.0").unwrap(), "^3.0.0".parse().unwrap());
151 assert_eq!(version_req("*").unwrap(), VersionReq::STAR);
152 assert_eq!(version_req("").unwrap(), VersionReq::STAR);
153 assert_eq!(version_req("latest").unwrap(), VersionReq::STAR);
154 let exact = version_req("1.2.3").unwrap();
156 assert!(exact.matches(&Version::parse("1.2.3").unwrap()));
157 assert!(!exact.matches(&Version::parse("1.2.4").unwrap()));
158 }
159
160 #[test]
161 fn classifies_registry_versions_ranges_and_tags() {
162 for s in [
163 "^1.2.3", "1.2.3", ">=1 <2", "~1.2.3", "*", "", "latest", "next",
164 ] {
165 assert!(matches!(Spec::parse(s), Spec::Registry(_)), "{s:?}");
166 assert!(Spec::parse(s).is_registry(), "{s:?}");
167 }
168 assert_eq!(Spec::parse(">=1 <2"), Spec::Registry(">=1 <2".into()));
170 assert_eq!(Spec::parse("latest"), Spec::Registry("latest".into()));
171 }
172
173 #[test]
174 fn classifies_npm_alias_to_its_inner_spec() {
175 match Spec::parse("npm:@scope/pkg@^1.2.3") {
176 Spec::Alias { name, spec } => {
177 assert_eq!(name, "@scope/pkg");
178 assert_eq!(*spec, Spec::Registry("^1.2.3".into()));
179 }
180 other => panic!("expected alias, got {other:?}"),
181 }
182 assert!(Spec::parse("npm:left-pad@1.0.0").is_registry());
184 }
185
186 #[test]
187 fn classifies_git_sources_with_committish() {
188 for s in [
189 "git+https://github.com/npm/cli.git",
190 "git+ssh://git@github.com/npm/cli.git",
191 "git://github.com/npm/cli.git",
192 "github:npm/cli",
193 "gitlab:owner/repo",
194 "bitbucket:owner/repo",
195 "npm/cli", ] {
197 assert!(matches!(Spec::parse(s), Spec::Git { .. }), "{s}");
198 assert!(!Spec::parse(s).is_registry(), "{s}");
199 }
200 match Spec::parse("npm/cli#v6.0.0") {
201 Spec::Git { source, committish } => {
202 assert_eq!(source, "npm/cli");
203 assert_eq!(committish.as_deref(), Some("v6.0.0"));
204 }
205 other => panic!("expected git, got {other:?}"),
206 }
207 }
208
209 #[test]
210 fn classifies_remote_tarballs_and_local_paths() {
211 assert!(matches!(
212 Spec::parse("https://registry.npmjs.org/semver/-/semver-1.0.0.tgz"),
213 Spec::Tarball(_)
214 ));
215 for p in ["file:../local", "./pkg", "../pkg", "/abs/pkg", "~/pkg"] {
216 assert!(matches!(Spec::parse(p), Spec::Path(_)), "{p}");
217 assert!(!Spec::parse(p).is_registry(), "{p}");
218 }
219 }
220}