Skip to main content

chaste_types/
svs.rs

1// SPDX-FileCopyrightText: 2024 The Chaste Authors
2// SPDX-License-Identifier: Apache-2.0 OR BSD-2-Clause
3
4use std::ops::{Range, RangeFrom};
5
6pub use nodejs_semver::Range as VersionRange;
7use nom::branch::alt;
8use nom::bytes::complete::{tag, take_while, take_while1};
9use nom::character::complete::digit1;
10use nom::combinator::{eof, map_res, opt, recognize, rest, verify};
11use nom::sequence::{pair, preceded, terminated};
12use nom::Parser;
13
14use crate::error::{Error, Result};
15use crate::name::{package_name, PackageNameBorrowed, PackageNamePositions};
16use crate::quirks::QuirksMode;
17
18/// Source/version specifier. It is a constraint defined by a specific [`crate::Dependency`],
19/// and is used by package managers to choose a specific [`crate::PackageSource`].
20///
21/// # Example
22/// ```
23/// # use chaste_types::SourceVersionSpecifier;
24/// let svs1 = SourceVersionSpecifier::new(
25///     "^1.0.0".to_string()).unwrap();
26/// assert!(svs1.is_npm());
27///
28/// let svs2 = SourceVersionSpecifier::new(
29///     "git@codeberg.org:22/selfisekai/chaste.git".to_string()).unwrap();
30/// assert!(svs2.is_git());
31///
32/// let svs3 = SourceVersionSpecifier::new(
33///     "https://s.lnl.gay/YMSRcUPRNMxx.tgz".to_string()).unwrap();
34/// assert!(svs3.is_tar());
35/// ```
36#[derive(Debug, Clone)]
37pub struct SourceVersionSpecifier {
38    inner: String,
39    positions: SourceVersionSpecifierPositions,
40}
41
42#[derive(Debug, Clone)]
43enum SourceVersionSpecifierPositions {
44    Npm {
45        type_prefix_end: usize,
46        alias_package_name: Option<PackageNamePositions>,
47    },
48    NpmTag {},
49    TarballURL {},
50    Git {
51        type_prefix_end: usize,
52        pre_path_sep_offset: Option<usize>,
53    },
54    GitHub {
55        type_prefix_end: usize,
56    },
57}
58
59fn npm(input: &str) -> Option<SourceVersionSpecifierPositions> {
60    (
61        opt(tag("npm:")),
62        opt(terminated(package_name, tag("@"))),
63        map_res(rest, |input: &str| {
64            VersionRange::parse(if input.is_empty() { "*" } else { input })
65        }),
66    )
67        .parse(input)
68        .ok()
69        .map(|(_, (type_prefix, alias_package_name, _range))| {
70            SourceVersionSpecifierPositions::Npm {
71                type_prefix_end: type_prefix.map(|p| p.len()).unwrap_or(0),
72                alias_package_name,
73            }
74        })
75}
76
77fn url(input: &str) -> Option<SourceVersionSpecifierPositions> {
78    (
79        opt(tag::<&str, &str, nom::error::Error<&str>>("git+")),
80        recognize((
81            tag("http"),
82            opt(tag("s")),
83            tag("://"),
84            take_while(|c| c != '#'),
85        )),
86        opt(preceded(tag("#"), rest)),
87    )
88        .parse(input)
89        .ok()
90        .map(|(_, (git_prefix, url, _spec_suffix))| {
91            if git_prefix.is_some() || url.ends_with(".git") {
92                SourceVersionSpecifierPositions::Git {
93                    type_prefix_end: git_prefix.map(|p| p.len()).unwrap_or(0),
94                    pre_path_sep_offset: None,
95                }
96            } else {
97                SourceVersionSpecifierPositions::TarballURL {}
98            }
99        })
100}
101
102/// This definition is probably too broad
103fn ssh(input: &str) -> Option<SourceVersionSpecifierPositions> {
104    (
105        opt(pair(
106            opt(tag::<&str, &str, nom::error::Error<&str>>("git+")),
107            tag("ssh://"),
108        )),
109        take_while(|c| !['/', ':'].contains(&c)),
110        opt(preceded(tag(":"), digit1)),
111        alt((tag(":"), tag("/"))),
112        take_while(|c| c != '#'),
113        opt(preceded(tag("#"), rest)),
114    )
115        .parse(input)
116        .ok()
117        .and_then(|(_, (prefix, host, port, _sep, url, _spec_suffix))| {
118            if prefix.is_some() || url.ends_with(".git") {
119                let prefix_len = prefix
120                    .map(|(git_prefix, ssh_prefix)| {
121                        git_prefix.map(|p| p.len()).unwrap_or(0) + ssh_prefix.len()
122                    })
123                    .unwrap_or(0);
124                Some(SourceVersionSpecifierPositions::Git {
125                    type_prefix_end: prefix_len,
126                    pre_path_sep_offset: Some(
127                        prefix_len + host.len() + port.map(|p| p.len() + 1).unwrap_or(0),
128                    ),
129                })
130            } else {
131                None
132            }
133        })
134}
135
136fn github(input: &str) -> Option<SourceVersionSpecifierPositions> {
137    (
138        opt(tag::<&str, &str, ()>("github:")),
139        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-'),
140        tag("/"),
141        verify(
142            take_while1(|c: char| c.is_ascii_alphanumeric() || ['-', '.', '_'].contains(&c)),
143            |name: &str| !name.starts_with("."),
144        ),
145        alt((tag("#"), eof)),
146    )
147        .parse(input)
148        .ok()
149        .map(
150            |(_, (gh_prefix, _, _, _, _))| SourceVersionSpecifierPositions::GitHub {
151                type_prefix_end: gh_prefix.map(|p| p.len()).unwrap_or(0),
152            },
153        )
154}
155
156fn npm_tag(input: &str) -> Option<SourceVersionSpecifierPositions> {
157    preceded(
158        take_while(|c: char| c.is_ascii() && !c.is_ascii_control()),
159        eof::<&str, nom::error::Error<&str>>,
160    )
161    .parse(input)
162    .ok()
163    .map(|(_, _)| SourceVersionSpecifierPositions::NpmTag {})
164}
165
166impl SourceVersionSpecifierPositions {
167    fn parse(svs: &str, quirks: Option<QuirksMode>) -> Result<Self> {
168        npm(svs)
169            .or_else(|| url(svs))
170            .or_else(|| github(svs))
171            .or_else(|| {
172                ssh(svs).filter(|s| {
173                    // in yarn(classic), "ssh://git@github.com:npm/node-semver.git" is interpreted as an npm tag
174                    !matches!(quirks, Some(QuirksMode::Yarn(1)))
175                        || s.ssh_path_sep()
176                            .map(|r| &svs[r])
177                            .is_none_or(|sep| sep == "/")
178                })
179            })
180            .or_else(|| npm_tag(svs))
181            .ok_or_else(|| Error::InvalidSVS(svs.to_string()))
182    }
183}
184impl SourceVersionSpecifier {
185    pub fn new(svs: String) -> Result<Self> {
186        Ok(Self {
187            positions: SourceVersionSpecifierPositions::parse(&svs, None)?,
188            inner: svs,
189        })
190    }
191
192    pub fn with_quirks(svs: String, quirks: QuirksMode) -> Result<Self> {
193        Ok(Self {
194            positions: SourceVersionSpecifierPositions::parse(&svs, Some(quirks))?,
195            inner: svs,
196        })
197    }
198}
199
200impl SourceVersionSpecifierPositions {
201    fn aliased_package_name(&self) -> Option<Range<usize>> {
202        match self {
203            SourceVersionSpecifierPositions::Npm {
204                type_prefix_end,
205                alias_package_name: Some(alias),
206            } => Some(*type_prefix_end..alias.total_length + type_prefix_end),
207            _ => None,
208        }
209    }
210    fn npm_range(&self) -> Option<RangeFrom<usize>> {
211        match self {
212            SourceVersionSpecifierPositions::Npm {
213                type_prefix_end,
214                alias_package_name: Some(alias),
215            } => Some(alias.total_length + type_prefix_end + 1..),
216            SourceVersionSpecifierPositions::Npm {
217                type_prefix_end,
218                alias_package_name: None,
219            } => Some(*type_prefix_end..),
220            _ => None,
221        }
222    }
223    fn ssh_path_sep(&self) -> Option<Range<usize>> {
224        match self {
225            SourceVersionSpecifierPositions::Git {
226                pre_path_sep_offset: Some(offset),
227                ..
228            } => Some(*offset..offset + 1),
229            _ => None,
230        }
231    }
232}
233
234impl AsRef<str> for SourceVersionSpecifier {
235    fn as_ref(&self) -> &str {
236        &self.inner
237    }
238}
239impl PartialEq<str> for SourceVersionSpecifier {
240    fn eq(&self, other: &str) -> bool {
241        self.inner == other
242    }
243}
244
245impl SourceVersionSpecifier {
246    /// Whether the SVS chooses an npm version range.
247    /// This does not include npm tags (see [`SourceVersionSpecifier::is_npm_tag`]).
248    ///
249    /// # Example
250    /// ```
251    /// # use chaste_types::SourceVersionSpecifier;
252    /// let svs1 = SourceVersionSpecifier::new(
253    ///     "^4".to_string()).unwrap();
254    /// assert!(svs1.is_npm());
255    ///
256    /// let svs2 = SourceVersionSpecifier::new(
257    ///     "npm:@chastelock/testcase@^2.1.37".to_string()).unwrap();
258    /// assert!(svs2.is_npm());
259    /// ```
260    pub fn is_npm(&self) -> bool {
261        matches!(self.positions, SourceVersionSpecifierPositions::Npm { .. })
262    }
263
264    /// Whether the SVS chooses an npm tag.
265    /// This does not include npm version ranges (see [`SourceVersionSpecifier::is_npm`]).
266    ///
267    /// # Example
268    /// ```
269    /// # use chaste_types::SourceVersionSpecifier;
270    /// let svs1 = SourceVersionSpecifier::new(
271    ///     "latest".to_string()).unwrap();
272    /// assert!(svs1.is_npm_tag());
273    ///
274    /// let svs2 = SourceVersionSpecifier::new(
275    ///     "next-11".to_string()).unwrap();
276    /// assert!(svs2.is_npm_tag());
277    /// ```
278    pub fn is_npm_tag(&self) -> bool {
279        matches!(
280            self.positions,
281            SourceVersionSpecifierPositions::NpmTag { .. }
282        )
283    }
284
285    /// Whether the SVS chooses an arbitrary tarball URL.
286    ///
287    /// # Example
288    /// ```
289    /// # use chaste_types::SourceVersionSpecifier;
290    /// let svs1 = SourceVersionSpecifier::new(
291    ///     "https://s.lnl.gay/YMSRcUPRNMxx.tgz".to_string()).unwrap();
292    /// assert!(svs1.is_tar());
293    ///
294    /// let svs2 = SourceVersionSpecifier::new(
295    ///     "https://codeberg.org/libselfisekai/-/packages/npm/@a%2Fempty/0.0.1/files/1152452".to_string()).unwrap();
296    /// assert!(svs2.is_tar());
297    /// ```
298    pub fn is_tar(&self) -> bool {
299        matches!(
300            self.positions,
301            SourceVersionSpecifierPositions::TarballURL { .. }
302        )
303    }
304
305    /// Whether the SVS chooses a git repository as the source.
306    /// This does not include the short-form GitHub slugs (see [`SourceVersionSpecifier::is_github`]).
307    ///
308    /// # Example
309    /// ```
310    /// # use chaste_types::SourceVersionSpecifier;
311    /// let svs1 = SourceVersionSpecifier::new(
312    ///     "ssh://git@github.com/npm/node-semver.git#semver:^7.5.0".to_string()).unwrap();
313    /// assert!(svs1.is_git());
314    ///
315    /// let svs2 = SourceVersionSpecifier::new(
316    ///     "https://github.com/isaacs/minimatch.git#v10.0.1".to_string()).unwrap();
317    /// assert!(svs2.is_git());
318    /// ```
319    pub fn is_git(&self) -> bool {
320        matches!(self.positions, SourceVersionSpecifierPositions::Git { .. })
321    }
322
323    /// Whether the SVS chooses a GitHub.com repository as the source.
324    /// This includes the short-form GitHub slugs, and does not include
325    /// full-formed Git URLs to github.com (for those, see [`SourceVersionSpecifier::is_git`]).
326    ///
327    /// While regular Git URLs specify a protocol, in this special case
328    /// a package manager can choose between Git over HTTPS, Git over SSH,
329    /// and tarball URLs. See [`crate::Package::source`] to find out the real source.
330    ///
331    /// # Example
332    /// ```
333    /// # use chaste_types::SourceVersionSpecifier;
334    /// let svs1 = SourceVersionSpecifier::new(
335    ///     "npm/node-semver#semver:^7.5.0".to_string()).unwrap();
336    /// assert!(svs1.is_github());
337    ///
338    /// let svs2 = SourceVersionSpecifier::new(
339    ///     "github:isaacs/minimatch#v10.0.1".to_string()).unwrap();
340    /// assert!(svs2.is_github());
341    /// ```
342    pub fn is_github(&self) -> bool {
343        matches!(
344            self.positions,
345            SourceVersionSpecifierPositions::GitHub { .. }
346        )
347    }
348
349    /// Package name specified as aliased in the version specifier.
350    ///
351    /// This is useful for a specific case: npm dependencies defined with a name alias,
352    /// e.g. `"lodash": "npm:@chastelock/lodash-fork@^4.1.0"`,
353    /// which means that the package `@chastelock/lodash-fork` is available for import
354    /// as `lodash`. Normally, there is no rename, and the package's registry name
355    /// (available in [`crate::Package::name`]) is used.
356    ///
357    /// # Example
358    /// ```
359    /// # use chaste_types::SourceVersionSpecifier;
360    /// let svs = SourceVersionSpecifier::new(
361    ///     "npm:@chastelock/testcase@^2.1.37".to_string()).unwrap();
362    /// assert_eq!(svs.aliased_package_name().unwrap(), "@chastelock/testcase");
363    /// ```
364    pub fn aliased_package_name(&self) -> Option<PackageNameBorrowed<'_>> {
365        match &self.positions {
366            SourceVersionSpecifierPositions::Npm {
367                alias_package_name: Some(positions),
368                ..
369            } => Some(PackageNameBorrowed {
370                inner: &self.inner[self.positions.aliased_package_name().unwrap()],
371                positions,
372            }),
373            _ => None,
374        }
375    }
376
377    /// Version range specified by a dependency, as a string.
378    ///
379    /// For a [VersionRange] object (to compare versions against), check out [SourceVersionSpecifier::npm_range].
380    ///
381    /// # Example
382    /// ```
383    /// # use chaste_types::SourceVersionSpecifier;
384    /// let svs1 = SourceVersionSpecifier::new(
385    ///     "4.2.x".to_string()).unwrap();
386    /// assert_eq!(svs1.npm_range_str().unwrap(), "4.2.x");
387    ///
388    /// let svs2 = SourceVersionSpecifier::new(
389    ///     "npm:@chastelock/testcase@^2.1.37".to_string()).unwrap();
390    /// assert_eq!(svs2.npm_range_str().unwrap(), "^2.1.37");
391    /// ```
392    pub fn npm_range_str(&self) -> Option<&str> {
393        self.positions.npm_range().map(|r| &self.inner[r])
394    }
395
396    /// Version range specified by a dependency, as [VersionRange] object.
397    ///
398    /// For a string slice, check out [SourceVersionSpecifier::npm_range_str].
399    ///
400    /// # Example
401    /// ```
402    /// # use chaste_types::SourceVersionSpecifier;
403    /// let svs1 = SourceVersionSpecifier::new(
404    ///     "4.2.x".to_string()).unwrap();
405    /// assert_eq!(svs1.npm_range_str().unwrap(), "4.2.x");
406    ///
407    /// let svs2 = SourceVersionSpecifier::new(
408    ///     "npm:@chastelock/testcase@^2.1.37".to_string()).unwrap();
409    /// assert_eq!(svs2.npm_range_str().unwrap(), "^2.1.37");
410    /// ```
411    pub fn npm_range(&self) -> Option<VersionRange> {
412        self.npm_range_str().map(|r| {
413            if r.is_empty() {
414                VersionRange::any()
415            } else {
416                VersionRange::parse(r).unwrap()
417            }
418        })
419    }
420
421    /// If the dependency source is Git over SSH, this returns the separator used
422    /// between the server part and the path. This is either `:` or `/`.
423    /// There are quirks in support for these addresses between implementations.
424    ///
425    /// # Example
426    /// ```
427    /// # use chaste_types::SourceVersionSpecifier;
428    /// let svs1 = SourceVersionSpecifier::new(
429    ///     "git@codeberg.org:selfisekai/chaste.git".to_string()).unwrap();
430    /// assert_eq!(svs1.ssh_path_sep().unwrap(), ":");
431    ///
432    /// let svs2 = SourceVersionSpecifier::new(
433    ///     "git@codeberg.org:22/selfisekai/chaste.git".to_string()).unwrap();
434    /// assert_eq!(svs2.ssh_path_sep().unwrap(), "/");
435    /// ```
436    pub fn ssh_path_sep(&self) -> Option<&str> {
437        self.positions.ssh_path_sep().map(|r| &self.inner[r])
438    }
439}
440
441#[non_exhaustive]
442pub enum SourceVersionSpecifierKind {
443    /// Package from an npm registry. Does not include tags (see [`SourceVersionSpecifierKind::NpmTag`])
444    Npm,
445    /// Named tag from an npm registry, e.g. "latest", "beta".
446    NpmTag,
447    /// Arbitrary tarball URL. <https://docs.npmjs.com/cli/v10/configuring-npm/package-json#urls-as-dependencies>
448    TarballURL,
449    /// Git repository. <https://docs.npmjs.com/cli/v10/configuring-npm/package-json#git-urls-as-dependencies>
450    Git,
451    /// GitHub repository. No, not the same as [`SourceVersionSpecifierKind::Git`], it's papa's special boy.
452    /// <https://docs.npmjs.com/cli/v10/configuring-npm/package-json#git-urls-as-dependencies>
453    GitHub,
454}
455
456impl SourceVersionSpecifier {
457    pub fn kind(&self) -> SourceVersionSpecifierKind {
458        match &self.positions {
459            SourceVersionSpecifierPositions::Npm { .. } => SourceVersionSpecifierKind::Npm,
460            SourceVersionSpecifierPositions::NpmTag { .. } => SourceVersionSpecifierKind::NpmTag,
461            SourceVersionSpecifierPositions::TarballURL { .. } => {
462                SourceVersionSpecifierKind::TarballURL
463            }
464            SourceVersionSpecifierPositions::Git { .. } => SourceVersionSpecifierKind::Git,
465            SourceVersionSpecifierPositions::GitHub { .. } => SourceVersionSpecifierKind::GitHub,
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::SourceVersionSpecifier;
473    use crate::error::Result;
474
475    #[test]
476    fn npm_svs_basic() -> Result<()> {
477        let svs = SourceVersionSpecifier::new("^7.0.1".to_string())?;
478        assert!(svs.is_npm());
479        assert_eq!(svs.aliased_package_name(), None);
480        Ok(())
481    }
482
483    #[test]
484    fn npm_svs_basic_any() -> Result<()> {
485        let svs = SourceVersionSpecifier::new("*".to_string())?;
486        assert!(svs.is_npm());
487        assert_eq!(svs.aliased_package_name(), None);
488        Ok(())
489    }
490
491    #[test]
492    fn npm_svs_basic_empty() -> Result<()> {
493        let svs = SourceVersionSpecifier::new("".to_string())?;
494        assert!(svs.is_npm());
495        assert_eq!(svs.aliased_package_name(), None);
496        assert!(svs
497            .npm_range()
498            .unwrap()
499            .satisfies(&nodejs_semver::Version::parse("1.0.0")?));
500        Ok(())
501    }
502
503    #[test]
504    fn npm_svs_alias() -> Result<()> {
505        let svs = SourceVersionSpecifier::new("npm:chazzwazzer@*".to_string())?;
506        assert!(svs.is_npm());
507        assert_eq!(svs.aliased_package_name().unwrap(), "chazzwazzer");
508        Ok(())
509    }
510
511    #[test]
512    fn npm_svs_alias_scoped() -> Result<()> {
513        let svs = SourceVersionSpecifier::new("@chastelock/testcase@1.0.x".to_string())?;
514        assert!(svs.is_npm());
515        assert_eq!(svs.aliased_package_name().unwrap(), "@chastelock/testcase");
516        Ok(())
517    }
518
519    #[test]
520    fn npm_svs_tag() -> Result<()> {
521        let svs = SourceVersionSpecifier::new("next-11".to_string())?;
522        assert!(svs.is_npm_tag());
523        Ok(())
524    }
525
526    #[test]
527    fn tar_svs() -> Result<()> {
528        let svs = SourceVersionSpecifier::new("https://example.com/not-a-git-repo".to_string())?;
529        assert!(svs.is_tar());
530        Ok(())
531    }
532
533    #[test]
534    fn git_http_svs_unspecified() -> Result<()> {
535        let svs =
536            SourceVersionSpecifier::new("https://codeberg.org/selfisekai/chaste.git".to_string())?;
537        assert!(svs.is_git());
538        Ok(())
539    }
540
541    #[test]
542    fn git_http_svs_unspecified_prefixed() -> Result<()> {
543        let svs =
544            SourceVersionSpecifier::new("git+https://codeberg.org/selfisekai/chaste".to_string())?;
545        assert!(svs.is_git());
546        Ok(())
547    }
548
549    #[test]
550    fn git_http_svs_tag() -> Result<()> {
551        let svs = SourceVersionSpecifier::new(
552            "https://github.com/npm/node-semver.git#v7.6.3".to_string(),
553        )?;
554        assert!(svs.is_git());
555        Ok(())
556    }
557
558    #[test]
559    fn git_http_svs_semver() -> Result<()> {
560        let svs = SourceVersionSpecifier::new(
561            "https://github.com/npm/node-semver.git#semver:^7.5.0".to_string(),
562        )?;
563        assert!(svs.is_git());
564        Ok(())
565    }
566
567    #[test]
568    fn git_ssh_svs_unspecified() -> Result<()> {
569        let svs =
570            SourceVersionSpecifier::new("git@codeberg.org:selfisekai/chaste.git".to_string())?;
571        assert!(svs.is_git());
572        assert_eq!(svs.ssh_path_sep(), Some(":"));
573        Ok(())
574    }
575
576    #[test]
577    fn git_ssh_svs_unspecified_prefixed() -> Result<()> {
578        let svs = SourceVersionSpecifier::new(
579            "git+ssh://git@codeberg.org:selfisekai/chaste".to_string(),
580        )?;
581        assert!(svs.is_git());
582        assert_eq!(svs.ssh_path_sep(), Some(":"));
583        Ok(())
584    }
585
586    #[test]
587    fn git_ssh_svs_tag() -> Result<()> {
588        let svs =
589            SourceVersionSpecifier::new("git@github.com:npm/node-semver.git#v7.6.3".to_string())?;
590        assert!(svs.is_git());
591        assert_eq!(svs.ssh_path_sep(), Some(":"));
592        Ok(())
593    }
594
595    #[test]
596    fn git_ssh_svs_semver() -> Result<()> {
597        let svs = SourceVersionSpecifier::new(
598            "git@github.com:npm/node-semver.git#semver:^7.5.0".to_string(),
599        )?;
600        assert!(svs.is_git());
601        assert_eq!(svs.ssh_path_sep(), Some(":"));
602        Ok(())
603    }
604
605    #[test]
606    fn github_svs_unspecified() -> Result<()> {
607        let svs = SourceVersionSpecifier::new("npm/node-semver".to_string())?;
608        assert!(svs.is_github());
609        Ok(())
610    }
611
612    #[test]
613    fn github_svs_unspecified_prefixed() -> Result<()> {
614        let svs = SourceVersionSpecifier::new("github:npm/node-semver".to_string())?;
615        assert!(svs.is_github());
616        Ok(())
617    }
618
619    #[test]
620    fn github_svs_tag() -> Result<()> {
621        let svs = SourceVersionSpecifier::new("npm/node-semver#7.5.1".to_string())?;
622        assert!(svs.is_github());
623        Ok(())
624    }
625
626    #[test]
627    fn github_svs_semver() -> Result<()> {
628        let svs = SourceVersionSpecifier::new("npm/node-semver#semver:^7.5.0".to_string())?;
629        assert!(svs.is_github());
630        Ok(())
631    }
632}