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/// Build a [`Spec::Git`], splitting off a `#committish` if present.
88fn 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
101/// Whether a spec value starts with an explicit git scheme or host shorthand.
102fn 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
116/// Whether a spec value is a bare `owner/repo` GitHub shorthand. Checked only *after* paths
117/// are ruled out: a slash, not scoped (`@`), and no URL scheme. A registry range never
118/// contains '/', so this is unambiguous here.
119fn 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
124/// Whether a spec value names a local path. `~1.2.3` (a tilde range) is *not* a path — only
125/// `~/…` (a home path) is.
126fn 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
134/// Split an `npm:` alias body into `(name, inner-spec)`, honoring scoped names: the version
135/// separator is the *last* `@` (a leading `@` is the scope, not a version marker).
136fn 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        // A bare version matches ONLY itself — npm's exact-pin semantics.
155        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        // The raw spec is preserved (incl. npm space-ranges we don't fully parse).
169        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        // An alias to a registry range is itself a fetchable registry install.
183        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", // bare owner/repo shorthand
196        ] {
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}