use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
pub struct Manifest {
pub package: Option<PackageMeta>,
#[serde(default)]
pub dependencies: HashMap<String, Dependency>,
#[serde(default)]
pub store: Option<StoreSection>,
}
#[derive(Debug, Default, Deserialize)]
pub struct StoreSection {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PackageMeta {
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub registry: Option<String>,
#[serde(default)]
pub lex: Option<String>,
}
pub fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut it = v.trim().trim_start_matches('v').split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch = it.next()?.parse().ok()?;
if it.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn satisfies_floor(running: &str, floor: &str) -> Option<bool> {
Some(parse_version(running)? >= parse_version(floor)?)
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Dependency {
Path { path: String },
Both {
registry: String,
version: String,
git: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
rev: Option<String>,
},
Git {
git: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
rev: Option<String>,
},
Registry { registry: String, version: String },
}
pub type GitCoord<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>);
impl Dependency {
pub fn validate(&self) -> Result<(), String> {
let refs = match self {
Dependency::Git { branch, tag, rev, .. } => [branch, tag, rev],
Dependency::Both { branch, tag, rev, .. } => [branch, tag, rev],
_ => return Ok(()),
};
if refs.iter().filter(|o| o.is_some()).count() > 1 {
return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
}
Ok(())
}
pub fn registry_coord(&self) -> Option<(&str, &str)> {
match self {
Dependency::Registry { registry, version }
| Dependency::Both { registry, version, .. } => Some((registry, version)),
_ => None,
}
}
pub fn git_coord(&self) -> Option<GitCoord<'_>> {
match self {
Dependency::Git { git, branch, tag, rev }
| Dependency::Both { git, branch, tag, rev, .. } => {
Some((git, branch.as_deref(), tag.as_deref(), rev.as_deref()))
}
_ => None,
}
}
}
impl Manifest {
pub fn load(toml_path: &Path) -> Result<Self, String> {
let src = std::fs::read_to_string(toml_path)
.map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
toml::from_str(&src)
.map_err(|e| format!("parsing {}: {e}", toml_path.display()))
}
}
pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
let mut dir = if start.is_dir() {
start.to_path_buf()
} else {
start.parent()?.to_path_buf()
};
loop {
let candidate = dir.join("lex.toml");
if candidate.exists() {
return Some((candidate, dir));
}
match dir.parent() {
Some(p) if p != dir => dir = p.to_path_buf(),
_ => return None,
}
}
}
pub fn resolve_package_import(
importer: &Path,
pkg_name: &str,
module_path: &str,
) -> Result<PathBuf, PackageError> {
let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
PackageError::NoManifest {
reference: format!("{pkg_name}/{module_path}"),
searched_from: importer.display().to_string(),
}
})?;
let manifest = Manifest::load(&toml_path)
.map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
PackageError::UnknownPackage {
name: pkg_name.to_string(),
manifest: toml_path.display().to_string(),
}
})?;
let pkg_root = match dep {
Dependency::Path { path } => {
let raw = toml_dir.join(path);
raw.canonicalize().map_err(|e| PackageError::Io {
path: raw.display().to_string(),
detail: e.to_string(),
})?
}
Dependency::Git { git, branch, tag, rev } => {
dep.validate().map_err(|e| PackageError::ManifestParse {
path: toml_path.display().to_string(),
detail: e,
})?;
let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
git_ensure_cached(pkg_name, git, &git_ref)?
}
Dependency::Registry { registry, version } => {
resolve_registry_dep(pkg_name, registry, version, &toml_dir)?
}
Dependency::Both { registry, version, git, branch, tag, rev } => {
dep.validate().map_err(|e| PackageError::ManifestParse {
path: toml_path.display().to_string(),
detail: e,
})?;
let resolve_git = || -> Result<PathBuf, PackageError> {
let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
git_ensure_cached(pkg_name, git, &git_ref)
};
if dep_source_prefers_git() {
resolve_git()?
} else {
match resolve_registry_dep(pkg_name, registry, version, &toml_dir) {
Ok(root) => root,
Err(PackageError::UnlockedRegistryDep { .. }) => resolve_git()?,
Err(e) => return Err(e),
}
}
}
};
find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
pkg: pkg_name.to_string(),
module: module_path.to_string(),
pkg_root: pkg_root.display().to_string(),
})
}
pub fn dep_source_prefers_git() -> bool {
std::env::var("LEX_DEP_SOURCE")
.map(|v| v.eq_ignore_ascii_case("git"))
.unwrap_or(false)
}
fn resolve_registry_dep(
pkg_name: &str,
registry: &str,
version: &str,
toml_dir: &Path,
) -> Result<PathBuf, PackageError> {
let locked = crate::lock::LockFile::load_dir(toml_dir)
.and_then(|lf| lf.entry(pkg_name).map(|e| e.version.clone()));
let effective = match locked {
Some(v) => v,
None if crate::semver::parse_exact(version).is_some() => version.to_string(),
None => {
return Err(PackageError::UnlockedRegistryDep {
name: pkg_name.to_string(),
constraint: version.to_string(),
});
}
};
registry_ensure_cached(pkg_name, registry, &effective)
}
fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
let rel = PathBuf::from(module_path).with_extension("lex");
let in_src = pkg_root.join("src").join(&rel);
if in_src.exists() {
return Some(in_src);
}
let at_root = pkg_root.join(&rel);
if at_root.exists() {
return Some(at_root);
}
None
}
#[derive(Debug)]
enum GitRef<'a> {
Branch(&'a str),
Tag(&'a str),
Rev(&'a str),
DefaultBranch,
}
impl<'a> GitRef<'a> {
fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
if let Some(b) = branch { return GitRef::Branch(b); }
if let Some(t) = tag { return GitRef::Tag(t); }
if let Some(r) = rev { return GitRef::Rev(r); }
GitRef::DefaultBranch
}
fn cache_slug(&self) -> String {
match self {
GitRef::Branch(b) => format!("@branch-{}", sanitize_ref(b)),
GitRef::Tag(t) => format!("@tag-{}", sanitize_ref(t)),
GitRef::Rev(r) => format!("@rev-{}", &r[..r.len().min(12)]),
GitRef::DefaultBranch => String::new(),
}
}
}
fn sanitize_ref(r: &str) -> String {
r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
}
fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
let cache_root = packages_cache_dir()?;
let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
let pkg_dir = cache_root.join(&dir_name);
if pkg_dir.exists() {
return Ok(pkg_dir);
}
std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
path: cache_root.display().to_string(),
detail: e.to_string(),
})?;
let dest = pkg_dir.to_str().unwrap_or(&dir_name);
let status = match git_ref {
GitRef::Rev(rev) => {
let s = run_git(&["clone", "--quiet", url, dest], url)?;
if s {
run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
true
} else {
false
}
}
GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
GitRef::DefaultBranch => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
};
if !status {
let _ = std::fs::remove_dir_all(&pkg_dir);
return Err(PackageError::GitFailed {
url: url.to_string(),
detail: "`git` exited with non-zero status".into(),
});
}
pkg_dir.canonicalize().map_err(|e| PackageError::Io {
path: pkg_dir.display().to_string(),
detail: e.to_string(),
})
}
fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
let status = std::process::Command::new("git")
.args(args)
.status()
.map_err(|e| PackageError::GitFailed {
url: url.to_string(),
detail: format!("could not run `git`: {e}"),
})?;
Ok(status.success())
}
fn registry_ensure_cached(
pkg_name: &str,
registry: &str,
version: &str,
) -> Result<PathBuf, PackageError> {
let cache_root = packages_cache_dir()?;
let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
if pkg_dir.exists() {
return Ok(pkg_dir);
}
std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
path: cache_root.display().to_string(),
detail: e.to_string(),
})?;
let url = match crate::registry::public(registry) {
Some(pr) => pr.archive_url(pkg_name, version),
None => format!(
"{}/v1/pkg/{}/{}/archive",
registry.trim_end_matches('/'),
pkg_name,
version,
),
};
let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
name: pkg_name.to_string(),
registry: registry.to_string(),
version: version.to_string(),
detail: format!("GET {url}: {e}"),
})?;
if response.status() != 200 {
return Err(PackageError::RegistryFailed {
name: pkg_name.to_string(),
registry: registry.to_string(),
version: version.to_string(),
detail: format!("GET {url} returned HTTP {}", response.status()),
});
}
let archive_bytes = response
.into_body()
.read_to_vec()
.map_err(|e| PackageError::RegistryFailed {
name: pkg_name.to_string(),
registry: registry.to_string(),
version: version.to_string(),
detail: format!("reading response body: {e}"),
})?;
let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
let mut ar = tar::Archive::new(gz);
ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
name: pkg_name.to_string(),
registry: registry.to_string(),
version: version.to_string(),
detail: format!("extracting archive: {e}"),
})?;
pkg_dir.canonicalize().map_err(|e| PackageError::Io {
path: pkg_dir.display().to_string(),
detail: e.to_string(),
})
}
pub fn packages_cache_root() -> Option<PathBuf> {
packages_cache_dir().ok()
}
fn packages_cache_dir() -> Result<PathBuf, PackageError> {
if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
return Ok(PathBuf::from(dir));
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| PackageError::Io {
path: "~/.lex/packages".into(),
detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
})?;
Ok(PathBuf::from(home).join(".lex").join("packages"))
}
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
NoManifest { reference: String, searched_from: String },
#[error("failed to parse {path}: {detail}")]
ManifestParse { path: String, detail: String },
#[error("package \"{name}\" not found in {manifest}")]
UnknownPackage { name: String, manifest: String },
#[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
ModuleNotFound { pkg: String, module: String, pkg_root: String },
#[error("git clone of {url} failed: {detail}")]
GitFailed { url: String, detail: String },
#[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
RegistryFailed { name: String, registry: String, version: String, detail: String },
#[error("registry dependency \"{name}\" declares constraint \"{constraint}\" but is not in lex.lock — run `lex pkg lock` to pin a version")]
UnlockedRegistryDep { name: String, constraint: String },
#[error("I/O error at {path}: {detail}")]
Io { path: String, detail: String },
}
#[cfg(test)]
mod floor_tests {
use super::{parse_version, satisfies_floor};
#[test]
fn parses_plain_and_v_prefixed() {
assert_eq!(parse_version("0.10.18"), Some((0, 10, 18)));
assert_eq!(parse_version("v0.10.18"), Some((0, 10, 18)));
assert_eq!(parse_version(" 1.2.3 "), Some((1, 2, 3)));
}
#[test]
fn refuses_what_it_cannot_read_rather_than_guessing() {
for bad in ["nightly", "0.10", "0.10.18.1", "", "0.x.1", "1.2.3-rc1"] {
assert_eq!(parse_version(bad), None, "should not parse: {bad}");
assert_eq!(satisfies_floor("0.10.18", bad), None);
assert_eq!(satisfies_floor(bad, "0.10.18"), None);
}
}
#[test]
fn compares_by_component_not_lexically() {
assert_eq!(satisfies_floor("0.10.11", "0.10.15"), Some(false));
assert_eq!(satisfies_floor("0.10.18", "0.10.15"), Some(true));
assert_eq!(satisfies_floor("0.10.9", "0.10.15"), Some(false));
assert_eq!(satisfies_floor("0.9.20", "0.10.0"), Some(false));
}
#[test]
fn an_exactly_met_floor_is_met() {
assert_eq!(satisfies_floor("0.10.15", "0.10.15"), Some(true));
}
#[test]
fn the_field_is_read_from_a_manifest() {
let m: super::Manifest = toml::from_str(
"[package]\nname = \"p\"\nversion = \"0.1.0\"\nlex = \"0.10.15\"\n",
)
.expect("parse");
assert_eq!(m.package.unwrap().lex.as_deref(), Some("0.10.15"));
}
#[test]
fn a_manifest_without_the_field_still_parses() {
let m: super::Manifest =
toml::from_str("[package]\nname = \"p\"\nversion = \"0.1.0\"\n").expect("parse");
assert_eq!(m.package.unwrap().lex, None);
}
}
#[cfg(test)]
mod dual_ref_tests {
use super::{Dependency, Manifest};
fn dep<'a>(m: &'a Manifest, name: &str) -> &'a Dependency {
m.dependencies.get(name).expect("dep present")
}
#[test]
fn a_dep_with_both_git_and_registry_parses_as_both_not_git() {
let m: Manifest = toml::from_str(
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
[dependencies]\n\
lex-schema = { registry = \"vcs.lexlang.org/lex-official/lex-schema\", version = \"^0.9\", git = \"https://github.com/alpibrusl/lex-schema\" }\n",
)
.expect("parse");
let d = dep(&m, "lex-schema");
assert!(matches!(d, Dependency::Both { .. }), "got {d:?}");
assert_eq!(
d.registry_coord(),
Some(("vcs.lexlang.org/lex-official/lex-schema", "^0.9")),
"vcs primary must be readable"
);
assert_eq!(
d.git_coord(),
Some(("https://github.com/alpibrusl/lex-schema", None, None, None)),
"git mirror must be readable"
);
d.validate().expect("valid");
}
#[test]
fn bare_git_and_bare_registry_still_parse_to_their_own_variants() {
let m: Manifest = toml::from_str(
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
[dependencies]\n\
g = { git = \"https://example.com/g\", tag = \"v1\" }\n\
r = { registry = \"vcs.example/tenant/r\", version = \"^1.0\" }\n",
)
.expect("parse");
assert!(matches!(dep(&m, "g"), Dependency::Git { .. }));
assert!(matches!(dep(&m, "r"), Dependency::Registry { .. }));
assert_eq!(dep(&m, "g").registry_coord(), None);
assert_eq!(dep(&m, "g").git_coord(), Some(("https://example.com/g", None, Some("v1"), None)));
assert_eq!(dep(&m, "r").registry_coord(), Some(("vcs.example/tenant/r", "^1.0")));
assert_eq!(dep(&m, "r").git_coord(), None);
}
#[test]
fn a_dual_dep_rejects_two_git_refs() {
let m: Manifest = toml::from_str(
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
[dependencies]\n\
d = { registry = \"vcs/r\", version = \"1.0.0\", git = \"https://x/g\", branch = \"main\", tag = \"v1\" }\n",
)
.expect("parse");
assert!(dep(&m, "d").validate().is_err(), "two git refs must be rejected");
}
}