Skip to main content

npm_utils/package_json/
spec.rs

1//! The npm "package spec" — the dependency-specifier grammar, per
2//! <https://docs.npmjs.com/cli/v8/using-npm/package-spec>.
3//!
4//! A `package.json` `dependencies` *value* is one of these forms. [`Spec::parse`] classifies
5//! a value by *form*; [`Spec::is_registry`] reports whether it resolves to a fetchable
6//! registry tarball — the only form `npm-utils` installs (git / remote-tarball / local-path /
7//! alias-to-non-registry are not). Range *parsing* is deferred to [`version_req`]: classifying
8//! never fails, so an npm range we can't fully parse (spaces, `||`) is still a registry spec.
9
10use semver::{Version, VersionReq};
11
12/// A classified npm dependency specifier.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Spec {
15    /// A registry spec — an exact version, a semver range, or a dist-tag (e.g. `latest`) —
16    /// held raw. Resolve it with [`version_req`] (the Rust `semver` subset of npm's grammar).
17    Registry(String),
18    /// An `npm:<name>@<spec>` alias — install `name` (per the inner spec) under the
19    /// dependency's own key.
20    Alias { name: String, spec: Box<Spec> },
21    /// A git source — a full git URL or a `host:owner/repo` / bare `owner/repo` shorthand —
22    /// with an optional `#<committish>` (branch, tag, commit, or `semver:<range>`).
23    Git {
24        source: String,
25        committish: Option<String>,
26    },
27    /// A remote tarball fetched over http(s).
28    Tarball(String),
29    /// A local path (`file:…`, `./`, `../`, `/abs`, `~/…`), linked or copied in place.
30    Path(String),
31}
32
33impl Spec {
34    /// Classify a `dependencies` value by form. Never fails — an unparseable-but-registry
35    /// range is still [`Spec::Registry`]; turning it into a [`VersionReq`] is a later step.
36    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        // After ruling out paths, a bare `owner/repo` is a GitHub shorthand.
56        if is_git_shorthand(s) {
57            return git_spec(s);
58        }
59        Spec::Registry(s.to_string())
60    }
61
62    /// Whether this spec resolves to a registry tarball (the only form `npm-utils` fetches).
63    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
72/// npm-faithful version → [`VersionReq`]: a bare full version (`1.2.3`) is an **exact** pin
73/// (`=1.2.3`); `*`, empty, `x`, and `latest` mean any; range syntax (`^`, `~`, `>=`, …) parses
74/// as written, within what the Rust `semver` crate accepts (comma-separated comparators; npm's
75/// space-separated and `||` ranges are not supported).
76pub 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/// An npm version **range**: `||`-separated alternatives, each a (possibly space-separated) set
91/// of comparators. Rust's [`VersionReq`] handles only comma-separated comparators and has no
92/// `||`, yet `||` ranges are pervasive in published packages' dependencies (e.g.
93/// `@lit/reactive-element`'s `^1.6.2 || ^2.1.0`). A [`Range`] parses npm's grammar into a set of
94/// [`VersionReq`]s and is satisfied when **any** alternative is — so transitive resolution works
95/// on real-world trees. ([`version_req`] stays for the single-comparator-set case.)
96#[derive(Debug, Clone)]
97pub struct Range {
98    alternatives: Vec<VersionReq>,
99}
100
101impl Range {
102    /// A range matching any version (`*`).
103    pub fn any() -> Range {
104        Range {
105            alternatives: vec![VersionReq::STAR],
106        }
107    }
108
109    /// Parse an npm range. `||` separates alternatives; within one, npm's space-separated
110    /// comparators are joined with commas for `semver`. A bare full version is an exact pin;
111    /// `*`/`x`/empty/`latest` match anything.
112    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    /// Whether `version` satisfies any alternative.
125    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
157/// Parse one `||`-free alternative: a bare full version → an exact pin; otherwise npm's
158/// space-separated comparators joined with commas (what `semver` expects). A bare alphabetic word
159/// is reported as an unsupported npm dist-tag rather than leaking a cryptic semver error.
160fn 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    // A bare partial numeric version is an npm x-range: `1` = `1.x`, `1.2` = `1.2.x`. The semver
168    // crate would read `1.2` as a caret (`^1.2`, `< 2.0.0`); normalize to the wildcard form so it
169    // means `>=1.2.0, <1.3.0` as npm intends.
170    if let Some(xrange) = bare_partial_xrange(alt) {
171        return Ok(VersionReq::parse(&xrange)?);
172    }
173    // A bare alphabetic word (`next`, `beta`, …) is an npm dist-tag, not a semver range. We don't
174    // resolve dist-tags (that needs a `dist-tags` lookup), so say so clearly. (`latest` is mapped
175    // to `*` earlier, in `Range::parse`.)
176    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
186/// Join npm's space-separated comparators with the commas `semver` expects, re-attaching an
187/// operator that node-semver allows to stand apart from its version: `>= 1.43.0 < 2` (as
188/// published, e.g., by `compressible` for `mime-db`) → `>=1.43.0, <2`. A trailing or doubled
189/// operator is left as-is for `semver` to reject.
190fn 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
201/// A comparator operator standing alone (its version detached by whitespace).
202fn is_bare_operator(s: &str) -> bool {
203    matches!(s, ">" | "<" | ">=" | "<=" | "=" | "^" | "~")
204}
205
206/// Whether `s` has the shape of an npm dist-tag — a bare word `[A-Za-z][A-Za-z0-9-]*` — as opposed
207/// to a semver range (which begins with a digit or a comparator like `^`/`~`/`>`/`<`/`=`). Used
208/// only to turn an unsupported tag into a clear error.
209fn 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
214/// A bare partial numeric version (`1`, `1.2`) rendered as npm's x-range wildcard (`1.*`, `1.2.*`).
215/// npm reads such a partial as `major.minor.x`, but the `semver` crate reads it as a caret, so this
216/// normalizes it. Returns `None` for anything that isn't one or two all-numeric components.
217fn 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
223/// Build a [`Spec::Git`], splitting off a `#committish` if present.
224fn 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
237/// Whether a spec value starts with an explicit git scheme or host shorthand.
238fn 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
252/// Whether a spec value is a bare `owner/repo` GitHub shorthand. Checked only *after* paths
253/// are ruled out: a slash, not scoped (`@`), and no URL scheme. A registry range never
254/// contains '/', so this is unambiguous here.
255fn 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
260/// Whether a spec value names a local path. `~1.2.3` (a tilde range) is *not* a path — only
261/// `~/…` (a home path) is.
262fn 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
270/// Split an `npm:` alias body into `(name, inner-spec)`, honoring scoped names: the version
271/// separator is the *last* `@` (a leading `@` is the scope, not a version marker).
272fn 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        // A bare version matches ONLY itself — npm's exact-pin semantics.
291        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        // The `||` OR-range that broke transitive resolution (e.g. @lit/reactive-element).
301        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        // Space-separated comparators (npm AND) are joined with commas for semver.
312        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        // node-semver also allows whitespace between an operator and its version — published
317        // trees carry it (compressible@2.0.18 declares mime-db ">= 1.43.0 < 2").
318        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        // A bare version is an exact pin; `*`/empty/`Range::any` match anything.
328        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        // `latest` resolves (≈ any); other dist-tags aren't supported, and must say so clearly
337        // rather than leak a raw semver parse error.
338        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        // A real range still parses.
347        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        // The raw spec is preserved (incl. npm space-ranges we don't fully parse).
359        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        // An alias to a registry range is itself a fetchable registry install.
373        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", // bare owner/repo shorthand
386        ] {
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    // ---- node-semver conformance ----
412    // Lock in that Range::parse + matches behave like node-semver for the forms npm-utils
413    // resolves, so a future change to the parsing layer can't silently drift.
414
415    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        // Caret on 0.x pins the minor.
428        assert!(m("^0.2.3", "0.2.9"));
429        assert!(!m("^0.2.3", "0.3.0"));
430        // Caret on 0.0.x pins the patch.
431        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        // npm treats x / X / * as wildcards; a bare partial version is equivalent.
448        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        // A caret range does not match a pre-release of a different (major, minor, patch) tuple.
471        assert!(!m("^1.2.3", "1.3.0-rc.1"));
472        assert!(!m("^1.2.3", "2.0.0-rc.1"));
473        // A pre-release range matches pre-releases of the same tuple, and the release.
474        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        // node-semver's `a - b` (== `>=a <=b`) isn't parsed by the underlying semver crate. Record
481        // the current limitation so that adding support later updates this test deliberately;
482        // tracked with the resolution-fidelity follow-up.
483        assert!(Range::parse("1.2.3 - 2.3.4").is_err());
484    }
485}