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
87#[derive(Debug, Clone)]
94pub struct Range {
95 alternatives: Vec<VersionReq>,
96}
97
98impl Range {
99 pub fn any() -> Range {
101 Range {
102 alternatives: vec![VersionReq::STAR],
103 }
104 }
105
106 pub fn parse(spec: &str) -> Result<Range, Box<dyn std::error::Error>> {
110 let spec = spec.trim();
111 if spec.is_empty() || spec == "*" || spec == "x" || spec == "latest" {
112 return Ok(Range::any());
113 }
114 let alternatives = spec
115 .split("||")
116 .map(|alt| parse_alternative(alt.trim()))
117 .collect::<Result<Vec<_>, _>>()?;
118 Ok(Range { alternatives })
119 }
120
121 pub fn matches(&self, version: &Version) -> bool {
123 self.alternatives.iter().any(|req| req.matches(version))
124 }
125}
126
127impl From<VersionReq> for Range {
128 fn from(req: VersionReq) -> Range {
129 Range {
130 alternatives: vec![req],
131 }
132 }
133}
134
135impl std::str::FromStr for Range {
136 type Err = Box<dyn std::error::Error>;
137 fn from_str(s: &str) -> Result<Range, Self::Err> {
138 Range::parse(s)
139 }
140}
141
142impl std::fmt::Display for Range {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 for (i, req) in self.alternatives.iter().enumerate() {
145 if i > 0 {
146 write!(f, " || ")?;
147 }
148 write!(f, "{req}")?;
149 }
150 Ok(())
151 }
152}
153
154fn parse_alternative(alt: &str) -> Result<VersionReq, Box<dyn std::error::Error>> {
158 if alt.is_empty() || alt == "*" || alt == "x" {
159 return Ok(VersionReq::STAR);
160 }
161 if Version::parse(alt).is_ok() {
162 return Ok(VersionReq::parse(&format!("={alt}"))?);
163 }
164 if looks_like_dist_tag(alt) {
168 return Err(format!(
169 "version {alt:?} looks like an npm dist-tag, which npm-utils doesn't resolve — pin a \
170 semver version or range (e.g. `^1.2.3`), or install from a package-lock.json"
171 )
172 .into());
173 }
174 Ok(VersionReq::parse(
175 &alt.split_whitespace().collect::<Vec<_>>().join(", "),
176 )?)
177}
178
179fn looks_like_dist_tag(s: &str) -> bool {
183 matches!(s.chars().next(), Some(c) if c.is_ascii_alphabetic())
184 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
185}
186
187fn git_spec(s: &str) -> Spec {
189 match s.split_once('#') {
190 Some((source, c)) => Spec::Git {
191 source: source.to_string(),
192 committish: Some(c.to_string()),
193 },
194 None => Spec::Git {
195 source: s.to_string(),
196 committish: None,
197 },
198 }
199}
200
201fn is_git_url(s: &str) -> bool {
203 const GIT_PREFIXES: &[&str] = &[
204 "git+",
205 "git://",
206 "git@",
207 "ssh://",
208 "github:",
209 "gitlab:",
210 "bitbucket:",
211 "gist:",
212 ];
213 GIT_PREFIXES.iter().any(|p| s.starts_with(p))
214}
215
216fn is_git_shorthand(s: &str) -> bool {
220 let head = s.split('#').next().unwrap_or(s);
221 head.contains('/') && !head.starts_with('@') && !head.contains("://")
222}
223
224fn is_path(s: &str) -> bool {
227 s.starts_with("file:")
228 || s.starts_with("./")
229 || s.starts_with("../")
230 || s.starts_with('/')
231 || s.starts_with("~/")
232}
233
234fn split_alias(rest: &str) -> (&str, &str) {
237 match rest.rfind('@') {
238 Some(at) if at > 0 => (&rest[..at], &rest[at + 1..]),
239 _ => (rest, ""),
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn version_req_pins_bare_versions_and_parses_ranges() {
249 assert_eq!(version_req("1.2.3").unwrap(), "=1.2.3".parse().unwrap());
250 assert_eq!(version_req("^3.0.0").unwrap(), "^3.0.0".parse().unwrap());
251 assert_eq!(version_req("*").unwrap(), VersionReq::STAR);
252 assert_eq!(version_req("").unwrap(), VersionReq::STAR);
253 assert_eq!(version_req("latest").unwrap(), VersionReq::STAR);
254 let exact = version_req("1.2.3").unwrap();
256 assert!(exact.matches(&Version::parse("1.2.3").unwrap()));
257 assert!(!exact.matches(&Version::parse("1.2.4").unwrap()));
258 }
259
260 #[test]
261 fn range_handles_or_and_space_separated_alternatives() {
262 let v = |s: &str| Version::parse(s).unwrap();
263
264 let r = Range::parse("^1.6.2 || ^2.1.0").unwrap();
266 assert!(r.matches(&v("1.6.2")));
267 assert!(r.matches(&v("1.9.0")));
268 assert!(r.matches(&v("2.1.0")));
269 assert!(
270 !r.matches(&v("2.0.0")),
271 "below the ^2.1.0 alternative's floor"
272 );
273 assert!(!r.matches(&v("3.0.0")));
274
275 let and = Range::parse(">=1.6.2 <2.0.0").unwrap();
277 assert!(and.matches(&v("1.9.0")));
278 assert!(!and.matches(&v("2.0.0")));
279
280 assert!(Range::parse("1.2.3").unwrap().matches(&v("1.2.3")));
282 assert!(!Range::parse("1.2.3").unwrap().matches(&v("1.2.4")));
283 assert!(Range::any().matches(&v("9.9.9")));
284 assert!(Range::parse("*").unwrap().matches(&v("9.9.9")));
285 }
286
287 #[test]
288 fn rejects_dist_tags_with_a_clear_message() {
289 assert!(Range::parse("latest").is_ok());
292 for tag in ["next", "beta", "canary"] {
293 let err = Range::parse(tag).unwrap_err().to_string();
294 assert!(
295 err.contains("dist-tag"),
296 "{tag:?} should give a dist-tag error, got: {err}"
297 );
298 }
299 assert!(Range::parse("^1.2.3").is_ok());
301 }
302
303 #[test]
304 fn classifies_registry_versions_ranges_and_tags() {
305 for s in [
306 "^1.2.3", "1.2.3", ">=1 <2", "~1.2.3", "*", "", "latest", "next",
307 ] {
308 assert!(matches!(Spec::parse(s), Spec::Registry(_)), "{s:?}");
309 assert!(Spec::parse(s).is_registry(), "{s:?}");
310 }
311 assert_eq!(Spec::parse(">=1 <2"), Spec::Registry(">=1 <2".into()));
313 assert_eq!(Spec::parse("latest"), Spec::Registry("latest".into()));
314 }
315
316 #[test]
317 fn classifies_npm_alias_to_its_inner_spec() {
318 match Spec::parse("npm:@scope/pkg@^1.2.3") {
319 Spec::Alias { name, spec } => {
320 assert_eq!(name, "@scope/pkg");
321 assert_eq!(*spec, Spec::Registry("^1.2.3".into()));
322 }
323 other => panic!("expected alias, got {other:?}"),
324 }
325 assert!(Spec::parse("npm:left-pad@1.0.0").is_registry());
327 }
328
329 #[test]
330 fn classifies_git_sources_with_committish() {
331 for s in [
332 "git+https://github.com/npm/cli.git",
333 "git+ssh://git@github.com/npm/cli.git",
334 "git://github.com/npm/cli.git",
335 "github:npm/cli",
336 "gitlab:owner/repo",
337 "bitbucket:owner/repo",
338 "npm/cli", ] {
340 assert!(matches!(Spec::parse(s), Spec::Git { .. }), "{s}");
341 assert!(!Spec::parse(s).is_registry(), "{s}");
342 }
343 match Spec::parse("npm/cli#v6.0.0") {
344 Spec::Git { source, committish } => {
345 assert_eq!(source, "npm/cli");
346 assert_eq!(committish.as_deref(), Some("v6.0.0"));
347 }
348 other => panic!("expected git, got {other:?}"),
349 }
350 }
351
352 #[test]
353 fn classifies_remote_tarballs_and_local_paths() {
354 assert!(matches!(
355 Spec::parse("https://registry.npmjs.org/semver/-/semver-1.0.0.tgz"),
356 Spec::Tarball(_)
357 ));
358 for p in ["file:../local", "./pkg", "../pkg", "/abs/pkg", "~/pkg"] {
359 assert!(matches!(Spec::parse(p), Spec::Path(_)), "{p}");
360 assert!(!Spec::parse(p).is_registry(), "{p}");
361 }
362 }
363}