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    VersionReq::parse(spec)
85}
86
87/// An npm version **range**: `||`-separated alternatives, each a (possibly space-separated) set
88/// of comparators. Rust's [`VersionReq`] handles only comma-separated comparators and has no
89/// `||`, yet `||` ranges are pervasive in published packages' dependencies (e.g.
90/// `@lit/reactive-element`'s `^1.6.2 || ^2.1.0`). A [`Range`] parses npm's grammar into a set of
91/// [`VersionReq`]s and is satisfied when **any** alternative is — so transitive resolution works
92/// on real-world trees. ([`version_req`] stays for the single-comparator-set case.)
93#[derive(Debug, Clone)]
94pub struct Range {
95    alternatives: Vec<VersionReq>,
96}
97
98impl Range {
99    /// A range matching any version (`*`).
100    pub fn any() -> Range {
101        Range {
102            alternatives: vec![VersionReq::STAR],
103        }
104    }
105
106    /// Parse an npm range. `||` separates alternatives; within one, npm's space-separated
107    /// comparators are joined with commas for `semver`. A bare full version is an exact pin;
108    /// `*`/`x`/empty/`latest` match anything.
109    pub fn parse(spec: &str) -> Result<Range, semver::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    /// Whether `version` satisfies any alternative.
122    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 = semver::Error;
137    fn from_str(s: &str) -> Result<Range, semver::Error> {
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
154/// Parse one `||`-free alternative: a bare full version → an exact pin; otherwise npm's
155/// space-separated comparators joined with commas (what `semver` expects).
156fn parse_alternative(alt: &str) -> Result<VersionReq, semver::Error> {
157    if alt.is_empty() || alt == "*" || alt == "x" {
158        return Ok(VersionReq::STAR);
159    }
160    if Version::parse(alt).is_ok() {
161        return VersionReq::parse(&format!("={alt}"));
162    }
163    VersionReq::parse(&alt.split_whitespace().collect::<Vec<_>>().join(", "))
164}
165
166/// Build a [`Spec::Git`], splitting off a `#committish` if present.
167fn git_spec(s: &str) -> Spec {
168    match s.split_once('#') {
169        Some((source, c)) => Spec::Git {
170            source: source.to_string(),
171            committish: Some(c.to_string()),
172        },
173        None => Spec::Git {
174            source: s.to_string(),
175            committish: None,
176        },
177    }
178}
179
180/// Whether a spec value starts with an explicit git scheme or host shorthand.
181fn is_git_url(s: &str) -> bool {
182    const GIT_PREFIXES: &[&str] = &[
183        "git+",
184        "git://",
185        "git@",
186        "ssh://",
187        "github:",
188        "gitlab:",
189        "bitbucket:",
190        "gist:",
191    ];
192    GIT_PREFIXES.iter().any(|p| s.starts_with(p))
193}
194
195/// Whether a spec value is a bare `owner/repo` GitHub shorthand. Checked only *after* paths
196/// are ruled out: a slash, not scoped (`@`), and no URL scheme. A registry range never
197/// contains '/', so this is unambiguous here.
198fn is_git_shorthand(s: &str) -> bool {
199    let head = s.split('#').next().unwrap_or(s);
200    head.contains('/') && !head.starts_with('@') && !head.contains("://")
201}
202
203/// Whether a spec value names a local path. `~1.2.3` (a tilde range) is *not* a path — only
204/// `~/…` (a home path) is.
205fn is_path(s: &str) -> bool {
206    s.starts_with("file:")
207        || s.starts_with("./")
208        || s.starts_with("../")
209        || s.starts_with('/')
210        || s.starts_with("~/")
211}
212
213/// Split an `npm:` alias body into `(name, inner-spec)`, honoring scoped names: the version
214/// separator is the *last* `@` (a leading `@` is the scope, not a version marker).
215fn split_alias(rest: &str) -> (&str, &str) {
216    match rest.rfind('@') {
217        Some(at) if at > 0 => (&rest[..at], &rest[at + 1..]),
218        _ => (rest, ""),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn version_req_pins_bare_versions_and_parses_ranges() {
228        assert_eq!(version_req("1.2.3").unwrap(), "=1.2.3".parse().unwrap());
229        assert_eq!(version_req("^3.0.0").unwrap(), "^3.0.0".parse().unwrap());
230        assert_eq!(version_req("*").unwrap(), VersionReq::STAR);
231        assert_eq!(version_req("").unwrap(), VersionReq::STAR);
232        assert_eq!(version_req("latest").unwrap(), VersionReq::STAR);
233        // A bare version matches ONLY itself — npm's exact-pin semantics.
234        let exact = version_req("1.2.3").unwrap();
235        assert!(exact.matches(&Version::parse("1.2.3").unwrap()));
236        assert!(!exact.matches(&Version::parse("1.2.4").unwrap()));
237    }
238
239    #[test]
240    fn range_handles_or_and_space_separated_alternatives() {
241        let v = |s: &str| Version::parse(s).unwrap();
242
243        // The `||` OR-range that broke transitive resolution (e.g. @lit/reactive-element).
244        let r = Range::parse("^1.6.2 || ^2.1.0").unwrap();
245        assert!(r.matches(&v("1.6.2")));
246        assert!(r.matches(&v("1.9.0")));
247        assert!(r.matches(&v("2.1.0")));
248        assert!(
249            !r.matches(&v("2.0.0")),
250            "below the ^2.1.0 alternative's floor"
251        );
252        assert!(!r.matches(&v("3.0.0")));
253
254        // Space-separated comparators (npm AND) are joined with commas for semver.
255        let and = Range::parse(">=1.6.2 <2.0.0").unwrap();
256        assert!(and.matches(&v("1.9.0")));
257        assert!(!and.matches(&v("2.0.0")));
258
259        // A bare version is an exact pin; `*`/empty/`Range::any` match anything.
260        assert!(Range::parse("1.2.3").unwrap().matches(&v("1.2.3")));
261        assert!(!Range::parse("1.2.3").unwrap().matches(&v("1.2.4")));
262        assert!(Range::any().matches(&v("9.9.9")));
263        assert!(Range::parse("*").unwrap().matches(&v("9.9.9")));
264    }
265
266    #[test]
267    fn classifies_registry_versions_ranges_and_tags() {
268        for s in [
269            "^1.2.3", "1.2.3", ">=1 <2", "~1.2.3", "*", "", "latest", "next",
270        ] {
271            assert!(matches!(Spec::parse(s), Spec::Registry(_)), "{s:?}");
272            assert!(Spec::parse(s).is_registry(), "{s:?}");
273        }
274        // The raw spec is preserved (incl. npm space-ranges we don't fully parse).
275        assert_eq!(Spec::parse(">=1 <2"), Spec::Registry(">=1 <2".into()));
276        assert_eq!(Spec::parse("latest"), Spec::Registry("latest".into()));
277    }
278
279    #[test]
280    fn classifies_npm_alias_to_its_inner_spec() {
281        match Spec::parse("npm:@scope/pkg@^1.2.3") {
282            Spec::Alias { name, spec } => {
283                assert_eq!(name, "@scope/pkg");
284                assert_eq!(*spec, Spec::Registry("^1.2.3".into()));
285            }
286            other => panic!("expected alias, got {other:?}"),
287        }
288        // An alias to a registry range is itself a fetchable registry install.
289        assert!(Spec::parse("npm:left-pad@1.0.0").is_registry());
290    }
291
292    #[test]
293    fn classifies_git_sources_with_committish() {
294        for s in [
295            "git+https://github.com/npm/cli.git",
296            "git+ssh://git@github.com/npm/cli.git",
297            "git://github.com/npm/cli.git",
298            "github:npm/cli",
299            "gitlab:owner/repo",
300            "bitbucket:owner/repo",
301            "npm/cli", // bare owner/repo shorthand
302        ] {
303            assert!(matches!(Spec::parse(s), Spec::Git { .. }), "{s}");
304            assert!(!Spec::parse(s).is_registry(), "{s}");
305        }
306        match Spec::parse("npm/cli#v6.0.0") {
307            Spec::Git { source, committish } => {
308                assert_eq!(source, "npm/cli");
309                assert_eq!(committish.as_deref(), Some("v6.0.0"));
310            }
311            other => panic!("expected git, got {other:?}"),
312        }
313    }
314
315    #[test]
316    fn classifies_remote_tarballs_and_local_paths() {
317        assert!(matches!(
318            Spec::parse("https://registry.npmjs.org/semver/-/semver-1.0.0.tgz"),
319            Spec::Tarball(_)
320        ));
321        for p in ["file:../local", "./pkg", "../pkg", "/abs/pkg", "~/pkg"] {
322            assert!(matches!(Spec::parse(p), Spec::Path(_)), "{p}");
323            assert!(!Spec::parse(p).is_registry(), "{p}");
324        }
325    }
326}