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 if let Some(xrange) = bare_partial_xrange(spec) {
85 return VersionReq::parse(&xrange);
86 }
87 VersionReq::parse(spec)
88}
89
90#[derive(Debug, Clone)]
97pub struct Range {
98 alternatives: Vec<VersionReq>,
99}
100
101impl Range {
102 pub fn any() -> Range {
104 Range {
105 alternatives: vec![VersionReq::STAR],
106 }
107 }
108
109 pub fn parse(spec: &str) -> Result<Range, Box<dyn std::error::Error + Send + Sync>> {
113 let spec = spec.trim();
114 if spec.is_empty() || spec == "*" || spec == "x" || spec == "latest" {
115 return Ok(Range::any());
116 }
117 let alternatives = spec
118 .split("||")
119 .map(|alt| parse_alternative(alt.trim()))
120 .collect::<Result<Vec<_>, _>>()?;
121 Ok(Range { alternatives })
122 }
123
124 pub fn matches(&self, version: &Version) -> bool {
126 self.alternatives.iter().any(|req| req.matches(version))
127 }
128}
129
130impl From<VersionReq> for Range {
131 fn from(req: VersionReq) -> Range {
132 Range {
133 alternatives: vec![req],
134 }
135 }
136}
137
138impl std::str::FromStr for Range {
139 type Err = Box<dyn std::error::Error + Send + Sync>;
140 fn from_str(s: &str) -> Result<Range, Self::Err> {
141 Range::parse(s)
142 }
143}
144
145impl std::fmt::Display for Range {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 for (i, req) in self.alternatives.iter().enumerate() {
148 if i > 0 {
149 write!(f, " || ")?;
150 }
151 write!(f, "{req}")?;
152 }
153 Ok(())
154 }
155}
156
157fn parse_alternative(alt: &str) -> Result<VersionReq, Box<dyn std::error::Error + Send + Sync>> {
161 if alt.is_empty() || alt == "*" || alt == "x" {
162 return Ok(VersionReq::STAR);
163 }
164 if Version::parse(alt).is_ok() {
165 return Ok(VersionReq::parse(&format!("={alt}"))?);
166 }
167 if let Some(xrange) = bare_partial_xrange(alt) {
171 return Ok(VersionReq::parse(&xrange)?);
172 }
173 if looks_like_dist_tag(alt) {
177 return Err(format!(
178 "version {alt:?} looks like an npm dist-tag, which npm-utils doesn't resolve — pin a \
179 semver version or range (e.g. `^1.2.3`), or install from a package-lock.json"
180 )
181 .into());
182 }
183 Ok(VersionReq::parse(&join_comparators(alt))?)
184}
185
186fn join_comparators(alt: &str) -> String {
191 let mut parts: Vec<String> = Vec::new();
192 for token in alt.split_whitespace() {
193 match parts.last_mut() {
194 Some(prev) if is_bare_operator(prev) => prev.push_str(token),
195 _ => parts.push(token.to_string()),
196 }
197 }
198 parts.join(", ")
199}
200
201fn is_bare_operator(s: &str) -> bool {
203 matches!(s, ">" | "<" | ">=" | "<=" | "=" | "^" | "~")
204}
205
206fn looks_like_dist_tag(s: &str) -> bool {
210 matches!(s.chars().next(), Some(c) if c.is_ascii_alphabetic())
211 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
212}
213
214fn bare_partial_xrange(spec: &str) -> Option<String> {
218 let parts: Vec<&str> = spec.split('.').collect();
219 let numeric = |p: &&str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit());
220 ((1..=2).contains(&parts.len()) && parts.iter().all(numeric)).then(|| format!("{spec}.*"))
221}
222
223fn git_spec(s: &str) -> Spec {
225 match s.split_once('#') {
226 Some((source, c)) => Spec::Git {
227 source: source.to_string(),
228 committish: Some(c.to_string()),
229 },
230 None => Spec::Git {
231 source: s.to_string(),
232 committish: None,
233 },
234 }
235}
236
237fn is_git_url(s: &str) -> bool {
239 const GIT_PREFIXES: &[&str] = &[
240 "git+",
241 "git://",
242 "git@",
243 "ssh://",
244 "github:",
245 "gitlab:",
246 "bitbucket:",
247 "gist:",
248 ];
249 GIT_PREFIXES.iter().any(|p| s.starts_with(p))
250}
251
252fn is_git_shorthand(s: &str) -> bool {
256 let head = s.split('#').next().unwrap_or(s);
257 head.contains('/') && !head.starts_with('@') && !head.contains("://")
258}
259
260fn is_path(s: &str) -> bool {
263 s.starts_with("file:")
264 || s.starts_with("./")
265 || s.starts_with("../")
266 || s.starts_with('/')
267 || s.starts_with("~/")
268}
269
270fn split_alias(rest: &str) -> (&str, &str) {
273 match rest.rfind('@') {
274 Some(at) if at > 0 => (&rest[..at], &rest[at + 1..]),
275 _ => (rest, ""),
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn version_req_pins_bare_versions_and_parses_ranges() {
285 assert_eq!(version_req("1.2.3").unwrap(), "=1.2.3".parse().unwrap());
286 assert_eq!(version_req("^3.0.0").unwrap(), "^3.0.0".parse().unwrap());
287 assert_eq!(version_req("*").unwrap(), VersionReq::STAR);
288 assert_eq!(version_req("").unwrap(), VersionReq::STAR);
289 assert_eq!(version_req("latest").unwrap(), VersionReq::STAR);
290 let exact = version_req("1.2.3").unwrap();
292 assert!(exact.matches(&Version::parse("1.2.3").unwrap()));
293 assert!(!exact.matches(&Version::parse("1.2.4").unwrap()));
294 }
295
296 #[test]
297 fn range_handles_or_and_space_separated_alternatives() {
298 let v = |s: &str| Version::parse(s).unwrap();
299
300 let r = Range::parse("^1.6.2 || ^2.1.0").unwrap();
302 assert!(r.matches(&v("1.6.2")));
303 assert!(r.matches(&v("1.9.0")));
304 assert!(r.matches(&v("2.1.0")));
305 assert!(
306 !r.matches(&v("2.0.0")),
307 "below the ^2.1.0 alternative's floor"
308 );
309 assert!(!r.matches(&v("3.0.0")));
310
311 let and = Range::parse(">=1.6.2 <2.0.0").unwrap();
313 assert!(and.matches(&v("1.9.0")));
314 assert!(!and.matches(&v("2.0.0")));
315
316 let detached = Range::parse(">= 1.43.0 < 2").unwrap();
319 assert!(detached.matches(&v("1.43.0")));
320 assert!(detached.matches(&v("1.52.0")));
321 assert!(!detached.matches(&v("1.42.0")));
322 assert!(!detached.matches(&v("2.0.0")));
323 let caret = Range::parse("^ 1.2.3").unwrap();
324 assert!(caret.matches(&v("1.9.0")));
325 assert!(!caret.matches(&v("2.0.0")));
326
327 assert!(Range::parse("1.2.3").unwrap().matches(&v("1.2.3")));
329 assert!(!Range::parse("1.2.3").unwrap().matches(&v("1.2.4")));
330 assert!(Range::any().matches(&v("9.9.9")));
331 assert!(Range::parse("*").unwrap().matches(&v("9.9.9")));
332 }
333
334 #[test]
335 fn rejects_dist_tags_with_a_clear_message() {
336 assert!(Range::parse("latest").is_ok());
339 for tag in ["next", "beta", "canary"] {
340 let err = Range::parse(tag).unwrap_err().to_string();
341 assert!(
342 err.contains("dist-tag"),
343 "{tag:?} should give a dist-tag error, got: {err}"
344 );
345 }
346 assert!(Range::parse("^1.2.3").is_ok());
348 }
349
350 #[test]
351 fn classifies_registry_versions_ranges_and_tags() {
352 for s in [
353 "^1.2.3", "1.2.3", ">=1 <2", "~1.2.3", "*", "", "latest", "next",
354 ] {
355 assert!(matches!(Spec::parse(s), Spec::Registry(_)), "{s:?}");
356 assert!(Spec::parse(s).is_registry(), "{s:?}");
357 }
358 assert_eq!(Spec::parse(">=1 <2"), Spec::Registry(">=1 <2".into()));
360 assert_eq!(Spec::parse("latest"), Spec::Registry("latest".into()));
361 }
362
363 #[test]
364 fn classifies_npm_alias_to_its_inner_spec() {
365 match Spec::parse("npm:@scope/pkg@^1.2.3") {
366 Spec::Alias { name, spec } => {
367 assert_eq!(name, "@scope/pkg");
368 assert_eq!(*spec, Spec::Registry("^1.2.3".into()));
369 }
370 other => panic!("expected alias, got {other:?}"),
371 }
372 assert!(Spec::parse("npm:left-pad@1.0.0").is_registry());
374 }
375
376 #[test]
377 fn classifies_git_sources_with_committish() {
378 for s in [
379 "git+https://github.com/npm/cli.git",
380 "git+ssh://git@github.com/npm/cli.git",
381 "git://github.com/npm/cli.git",
382 "github:npm/cli",
383 "gitlab:owner/repo",
384 "bitbucket:owner/repo",
385 "npm/cli", ] {
387 assert!(matches!(Spec::parse(s), Spec::Git { .. }), "{s}");
388 assert!(!Spec::parse(s).is_registry(), "{s}");
389 }
390 match Spec::parse("npm/cli#v6.0.0") {
391 Spec::Git { source, committish } => {
392 assert_eq!(source, "npm/cli");
393 assert_eq!(committish.as_deref(), Some("v6.0.0"));
394 }
395 other => panic!("expected git, got {other:?}"),
396 }
397 }
398
399 #[test]
400 fn classifies_remote_tarballs_and_local_paths() {
401 assert!(matches!(
402 Spec::parse("https://registry.npmjs.org/semver/-/semver-1.0.0.tgz"),
403 Spec::Tarball(_)
404 ));
405 for p in ["file:../local", "./pkg", "../pkg", "/abs/pkg", "~/pkg"] {
406 assert!(matches!(Spec::parse(p), Spec::Path(_)), "{p}");
407 assert!(!Spec::parse(p).is_registry(), "{p}");
408 }
409 }
410
411 fn m(range: &str, ver: &str) -> bool {
416 Range::parse(range)
417 .unwrap_or_else(|e| panic!("range {range:?} failed to parse: {e}"))
418 .matches(&Version::parse(ver).unwrap())
419 }
420
421 #[test]
422 fn conformance_caret() {
423 assert!(m("^1.2.3", "1.2.3"));
424 assert!(m("^1.2.3", "1.9.0"));
425 assert!(!m("^1.2.3", "2.0.0"));
426 assert!(!m("^1.2.3", "1.2.2"));
427 assert!(m("^0.2.3", "0.2.9"));
429 assert!(!m("^0.2.3", "0.3.0"));
430 assert!(m("^0.0.3", "0.0.3"));
432 assert!(!m("^0.0.3", "0.0.4"));
433 }
434
435 #[test]
436 fn conformance_tilde() {
437 assert!(m("~1.2.3", "1.2.9"));
438 assert!(!m("~1.2.3", "1.3.0"));
439 assert!(m("~1.2", "1.2.0"));
440 assert!(!m("~1.2", "1.3.0"));
441 assert!(m("~1", "1.9.9"));
442 assert!(!m("~1", "2.0.0"));
443 }
444
445 #[test]
446 fn conformance_x_ranges() {
447 for r in ["1.x", "1.X", "1.*", "1"] {
449 assert!(m(r, "1.0.0"), "{r}");
450 assert!(m(r, "1.9.9"), "{r}");
451 assert!(!m(r, "2.0.0"), "{r}");
452 }
453 for r in ["1.2.x", "1.2.X", "1.2.*", "1.2"] {
454 assert!(m(r, "1.2.9"), "{r}");
455 assert!(!m(r, "1.3.0"), "{r}");
456 }
457 }
458
459 #[test]
460 fn conformance_exact_and_wildcard() {
461 assert!(m("1.2.3", "1.2.3"));
462 assert!(!m("1.2.3", "1.2.4"));
463 for star in ["*", "x", "", "latest"] {
464 assert!(m(star, "9.9.9"), "{star:?}");
465 }
466 }
467
468 #[test]
469 fn conformance_prerelease() {
470 assert!(!m("^1.2.3", "1.3.0-rc.1"));
472 assert!(!m("^1.2.3", "2.0.0-rc.1"));
473 assert!(m("^1.2.3-rc.1", "1.2.3-rc.2"));
475 assert!(m("^1.2.3-rc.1", "1.2.3"));
476 }
477
478 #[test]
479 fn hyphen_ranges_are_not_yet_supported() {
480 assert!(Range::parse("1.2.3 - 2.3.4").is_err());
484 }
485}