use anyhow::anyhow;
use once_cell::sync::Lazy;
use regex::Regex;
use std::fmt;
use crate::util;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct ImportPath {
path: String,
package: String,
has_domain: bool,
}
pub(crate) static PACKAGE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect(BAD_RE));
static PATH_ELEM_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-zA-Z0-9._\-~]+$").expect(BAD_RE));
static PATH_ELEM_DENY_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"~[0-9]+$").expect(BAD_RE));
static DOMAIN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9.-]+$").expect(BAD_RE));
static VERSION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^v[0-9.]+$").expect(BAD_RE));
const BAD_RE: &str = "invalid regex literal";
impl ImportPath {
pub fn parse(path: &str) -> Result<ImportPath, anyhow::Error> {
Self::do_parse(path, false)
}
pub fn parse_external_dep(path: &str) -> Result<ImportPath, anyhow::Error> {
Self::do_parse(path, true)
}
fn do_parse(path: &str, expect_domain: bool) -> Result<ImportPath, anyhow::Error> {
let mut last: Option<&str> = None;
let mut before_last: Option<&str> = None;
let mut has_domain = false;
for elem in path.split('/') {
Self::check_path_element(elem)?;
if last.is_none() {
let d = Self::check_domain(elem);
has_domain = d.is_ok();
if expect_domain {
d?;
}
} else {
before_last = last;
}
last = Some(elem);
}
let last = last.ok_or(anyhow!(
"import path must consist of at least one valid path element"
))?;
let has_version = Self::check_version_suffix(last)?;
let package = if has_version {
before_last.ok_or(anyhow!("import path must contain a non-version element"))?
} else {
last
};
Self::check_package(package)?;
Ok(ImportPath {
path: path.into(),
package: package.into(),
has_domain,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.path
}
#[must_use]
pub fn package(&self) -> &str {
&self.package
}
#[must_use]
pub fn has_domain(&self) -> bool {
self.has_domain
}
pub fn join(&self, rel: &str) -> Result<ImportPath, anyhow::Error> {
let mut path = self.path.clone();
path += "/";
path += rel;
Self::do_parse(&path, false)
}
fn check_path_element(elem: &str) -> Result<(), anyhow::Error> {
if elem.is_empty() {
return Err(anyhow!("import path element must be non-empty"));
}
if elem.starts_with('.') || elem.ends_with('.') {
return Err(anyhow!(
"import path element must not start nor end with a dot"
));
}
if elem.contains("..") {
return Err(anyhow!(
"import path element must not contain two dots in a row"
));
}
if !PATH_ELEM_RE.is_match(elem) {
let re = PATH_ELEM_RE.as_str();
return Err(anyhow!("import path element must match {re}"));
}
let prefix = match elem.split_once('.') {
Some(s) => s.0,
None => elem,
};
if PATH_ELEM_DENY_RE.is_match(prefix) {
let re = PATH_ELEM_DENY_RE.as_str();
return Err(anyhow!("import path element prefix must not match {re}"));
}
if util::is_reserved_windows_filename(prefix) {
return Err(anyhow!("import path element must not have {prefix} prefix"));
}
Ok(())
}
fn check_domain(elem: &str) -> Result<(), anyhow::Error> {
if !DOMAIN_RE.is_match(elem) {
let re = DOMAIN_RE.as_str();
return Err(anyhow!("import path domain must match {re}"));
}
if elem.starts_with('-') {
return Err(anyhow!("import path domain must not start with a dash"));
}
if !elem.contains('.') {
return Err(anyhow!("import path domain must contain a dot"));
}
Ok(())
}
fn check_version_suffix(elem: &str) -> Result<bool, anyhow::Error> {
if !VERSION_RE.is_match(elem) {
return Ok(false);
}
if elem == "v1" {
return Err(anyhow!("import path version suffix must not be v1"));
}
if elem.starts_with("v0") {
return Err(anyhow!("import path version suffix must not start with v0"));
}
if elem.contains('.') {
return Err(anyhow!("import path version suffix must not contain dots"));
}
Ok(true)
}
fn check_package(elem: &str) -> Result<(), anyhow::Error> {
if !PACKAGE_RE.is_match(elem) {
let re = PACKAGE_RE.as_str();
return Err(anyhow!("import path package name must match {re}"));
}
Ok(())
}
}
impl fmt::Display for ImportPath {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.as_str().fmt(f)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn parse_path() -> Result<(), anyhow::Error> {
let expected: HashMap<&str, ImportPath> = HashMap::from([
(
"fmt",
ImportPath {
path: "fmt".into(),
package: "fmt".into(),
has_domain: false,
},
),
(
"math/bits",
ImportPath {
path: "math/bits".into(),
package: "bits".into(),
has_domain: false,
},
),
(
"math/bits/v2",
ImportPath {
path: "math/bits/v2".into(),
package: "bits".into(),
has_domain: false,
},
),
(
"example/hello",
ImportPath {
path: "example/hello".into(),
package: "hello".into(),
has_domain: false,
},
),
(
"example.org/hello",
ImportPath {
path: "example.org/hello".into(),
package: "hello".into(),
has_domain: true,
},
),
(
"github.com/fennec-lang/fennec",
ImportPath {
path: "github.com/fennec-lang/fennec".into(),
package: "fennec".into(),
has_domain: true,
},
),
(
"github.com/fennec-lang/fennec/v2/test",
ImportPath {
path: "github.com/fennec-lang/fennec/v2/test".into(),
package: "test".into(),
has_domain: true,
},
),
]);
for p in expected {
let r = ImportPath::parse(p.0)?;
assert_eq!(r, p.1);
}
let errors = [
"",
"/test",
"test/",
"/test/",
"test.mod",
"v2",
"test/v1",
"con",
"test/com1",
];
for e in errors {
let r = ImportPath::parse(e);
assert!(r.is_err());
}
let dep_errors = [
"test",
"test/hello",
"example..org/test",
"example.org//test",
"example.org/CON.2/test",
"example.org/hello~0/test",
"example.org/hello~0.com/test",
"example.org/hello/test~",
"example.org/test/v1",
"example.org/test/v0.1",
"example.org/test/v2.5",
];
for e in dep_errors {
let r = ImportPath::parse_external_dep(e);
assert!(r.is_err());
}
Ok(())
}
}