1use 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#[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
102fn 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 !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 pub fn is_npm(&self) -> bool {
261 matches!(self.positions, SourceVersionSpecifierPositions::Npm { .. })
262 }
263
264 pub fn is_npm_tag(&self) -> bool {
279 matches!(
280 self.positions,
281 SourceVersionSpecifierPositions::NpmTag { .. }
282 )
283 }
284
285 pub fn is_tar(&self) -> bool {
299 matches!(
300 self.positions,
301 SourceVersionSpecifierPositions::TarballURL { .. }
302 )
303 }
304
305 pub fn is_git(&self) -> bool {
320 matches!(self.positions, SourceVersionSpecifierPositions::Git { .. })
321 }
322
323 pub fn is_github(&self) -> bool {
343 matches!(
344 self.positions,
345 SourceVersionSpecifierPositions::GitHub { .. }
346 )
347 }
348
349 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 pub fn npm_range_str(&self) -> Option<&str> {
393 self.positions.npm_range().map(|r| &self.inner[r])
394 }
395
396 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 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 Npm,
445 NpmTag,
447 TarballURL,
449 Git,
451 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}