1use serde::Deserialize;
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45#[derive(Debug, Deserialize)]
48pub struct Manifest {
49 pub package: Option<PackageMeta>,
50 #[serde(default)]
51 pub dependencies: HashMap<String, Dependency>,
52 #[serde(default)]
56 pub store: Option<StoreSection>,
57}
58
59#[derive(Debug, Default, Deserialize)]
61pub struct StoreSection {
62 #[serde(default)]
65 pub path: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct PackageMeta {
70 pub name: String,
71 #[serde(default)]
72 pub version: String,
73 #[serde(default)]
74 pub description: Option<String>,
75 #[serde(default)]
77 pub registry: Option<String>,
78 #[serde(default)]
87 pub lex: Option<String>,
88}
89
90pub fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
97 let mut it = v.trim().trim_start_matches('v').split('.');
98 let major = it.next()?.parse().ok()?;
99 let minor = it.next()?.parse().ok()?;
100 let patch = it.next()?.parse().ok()?;
101 if it.next().is_some() {
102 return None;
103 }
104 Some((major, minor, patch))
105}
106
107pub fn satisfies_floor(running: &str, floor: &str) -> Option<bool> {
112 Some(parse_version(running)? >= parse_version(floor)?)
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(untagged)]
117pub enum Dependency {
118 Path { path: String },
119 Both {
127 registry: String,
128 version: String,
129 git: String,
130 #[serde(default)]
131 branch: Option<String>,
132 #[serde(default)]
133 tag: Option<String>,
134 #[serde(default)]
135 rev: Option<String>,
136 },
137 Git {
138 git: String,
139 #[serde(default)]
140 branch: Option<String>,
141 #[serde(default)]
142 tag: Option<String>,
143 #[serde(default)]
144 rev: Option<String>,
145 },
146 Registry { registry: String, version: String },
147}
148
149pub type GitCoord<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>);
152
153impl Dependency {
154 pub fn validate(&self) -> Result<(), String> {
156 let refs = match self {
157 Dependency::Git { branch, tag, rev, .. } => [branch, tag, rev],
158 Dependency::Both { branch, tag, rev, .. } => [branch, tag, rev],
159 _ => return Ok(()),
160 };
161 if refs.iter().filter(|o| o.is_some()).count() > 1 {
162 return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
163 }
164 Ok(())
165 }
166
167 pub fn registry_coord(&self) -> Option<(&str, &str)> {
171 match self {
172 Dependency::Registry { registry, version }
173 | Dependency::Both { registry, version, .. } => Some((registry, version)),
174 _ => None,
175 }
176 }
177
178 pub fn git_coord(&self) -> Option<GitCoord<'_>> {
182 match self {
183 Dependency::Git { git, branch, tag, rev }
184 | Dependency::Both { git, branch, tag, rev, .. } => {
185 Some((git, branch.as_deref(), tag.as_deref(), rev.as_deref()))
186 }
187 _ => None,
188 }
189 }
190}
191
192impl Manifest {
193 pub fn load(toml_path: &Path) -> Result<Self, String> {
194 let src = std::fs::read_to_string(toml_path)
195 .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
196 toml::from_str(&src)
197 .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
198 }
199}
200
201pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
206 let mut dir = if start.is_dir() {
207 start.to_path_buf()
208 } else {
209 start.parent()?.to_path_buf()
210 };
211 loop {
212 let candidate = dir.join("lex.toml");
213 if candidate.exists() {
214 return Some((candidate, dir));
215 }
216 match dir.parent() {
217 Some(p) if p != dir => dir = p.to_path_buf(),
218 _ => return None,
219 }
220 }
221}
222
223pub fn resolve_package_import(
230 importer: &Path,
231 pkg_name: &str,
232 module_path: &str,
233) -> Result<PathBuf, PackageError> {
234 let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
235 PackageError::NoManifest {
236 reference: format!("{pkg_name}/{module_path}"),
237 searched_from: importer.display().to_string(),
238 }
239 })?;
240
241 let manifest = Manifest::load(&toml_path)
242 .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
243
244 let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
245 PackageError::UnknownPackage {
246 name: pkg_name.to_string(),
247 manifest: toml_path.display().to_string(),
248 }
249 })?;
250
251 let pkg_root = match dep {
252 Dependency::Path { path } => {
253 let raw = toml_dir.join(path);
254 raw.canonicalize().map_err(|e| PackageError::Io {
255 path: raw.display().to_string(),
256 detail: e.to_string(),
257 })?
258 }
259 Dependency::Git { git, branch, tag, rev } => {
260 dep.validate().map_err(|e| PackageError::ManifestParse {
261 path: toml_path.display().to_string(),
262 detail: e,
263 })?;
264 let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
265 git_ensure_cached(pkg_name, git, &git_ref)?
266 }
267 Dependency::Registry { registry, version } => {
268 resolve_registry_dep(pkg_name, registry, version, &toml_dir)?
269 }
270 Dependency::Both { registry, version, git, branch, tag, rev } => {
271 dep.validate().map_err(|e| PackageError::ManifestParse {
272 path: toml_path.display().to_string(),
273 detail: e,
274 })?;
275 let resolve_git = || -> Result<PathBuf, PackageError> {
281 let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
282 git_ensure_cached(pkg_name, git, &git_ref)
283 };
284 if dep_source_prefers_git() {
285 resolve_git()?
286 } else {
287 match resolve_registry_dep(pkg_name, registry, version, &toml_dir) {
288 Ok(root) => root,
289 Err(PackageError::UnlockedRegistryDep { .. }) => resolve_git()?,
290 Err(e) => return Err(e),
291 }
292 }
293 }
294 };
295
296 find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
297 pkg: pkg_name.to_string(),
298 module: module_path.to_string(),
299 pkg_root: pkg_root.display().to_string(),
300 })
301}
302
303pub fn dep_source_prefers_git() -> bool {
308 std::env::var("LEX_DEP_SOURCE")
309 .map(|v| v.eq_ignore_ascii_case("git"))
310 .unwrap_or(false)
311}
312
313fn resolve_registry_dep(
319 pkg_name: &str,
320 registry: &str,
321 version: &str,
322 toml_dir: &Path,
323) -> Result<PathBuf, PackageError> {
324 let locked = crate::lock::LockFile::load_dir(toml_dir)
325 .and_then(|lf| lf.entry(pkg_name).map(|e| e.version.clone()));
326 let effective = match locked {
327 Some(v) => v,
328 None if crate::semver::parse_exact(version).is_some() => version.to_string(),
329 None => {
330 return Err(PackageError::UnlockedRegistryDep {
331 name: pkg_name.to_string(),
332 constraint: version.to_string(),
333 });
334 }
335 };
336 registry_ensure_cached(pkg_name, registry, &effective)
337}
338
339fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
342 let rel = PathBuf::from(module_path).with_extension("lex");
343 let in_src = pkg_root.join("src").join(&rel);
344 if in_src.exists() {
345 return Some(in_src);
346 }
347 let at_root = pkg_root.join(&rel);
348 if at_root.exists() {
349 return Some(at_root);
350 }
351 None
352}
353
354#[derive(Debug)]
358enum GitRef<'a> {
359 Branch(&'a str),
360 Tag(&'a str),
361 Rev(&'a str),
362 DefaultBranch,
363}
364
365impl<'a> GitRef<'a> {
366 fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
367 if let Some(b) = branch { return GitRef::Branch(b); }
368 if let Some(t) = tag { return GitRef::Tag(t); }
369 if let Some(r) = rev { return GitRef::Rev(r); }
370 GitRef::DefaultBranch
371 }
372
373 fn cache_slug(&self) -> String {
376 match self {
377 GitRef::Branch(b) => format!("@branch-{}", sanitize_ref(b)),
378 GitRef::Tag(t) => format!("@tag-{}", sanitize_ref(t)),
379 GitRef::Rev(r) => format!("@rev-{}", &r[..r.len().min(12)]),
380 GitRef::DefaultBranch => String::new(),
381 }
382 }
383}
384
385fn sanitize_ref(r: &str) -> String {
387 r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
388}
389
390fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
396 let cache_root = packages_cache_dir()?;
397 let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
398 let pkg_dir = cache_root.join(&dir_name);
399 if pkg_dir.exists() {
400 return Ok(pkg_dir);
401 }
402 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
403 path: cache_root.display().to_string(),
404 detail: e.to_string(),
405 })?;
406
407 let dest = pkg_dir.to_str().unwrap_or(&dir_name);
408
409 let status = match git_ref {
410 GitRef::Rev(rev) => {
411 let s = run_git(&["clone", "--quiet", url, dest], url)?;
414 if s {
415 run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
416 true
417 } else {
418 false
419 }
420 }
421 GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
422 GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
423 GitRef::DefaultBranch => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
424 };
425
426 if !status {
427 let _ = std::fs::remove_dir_all(&pkg_dir);
429 return Err(PackageError::GitFailed {
430 url: url.to_string(),
431 detail: "`git` exited with non-zero status".into(),
432 });
433 }
434
435 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
436 path: pkg_dir.display().to_string(),
437 detail: e.to_string(),
438 })
439}
440
441fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
444 let status = std::process::Command::new("git")
445 .args(args)
446 .status()
447 .map_err(|e| PackageError::GitFailed {
448 url: url.to_string(),
449 detail: format!("could not run `git`: {e}"),
450 })?;
451 Ok(status.success())
452}
453
454fn registry_ensure_cached(
464 pkg_name: &str,
465 registry: &str,
466 version: &str,
467) -> Result<PathBuf, PackageError> {
468 let cache_root = packages_cache_dir()?;
469 let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
472 if pkg_dir.exists() {
473 return Ok(pkg_dir);
474 }
475 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
476 path: cache_root.display().to_string(),
477 detail: e.to_string(),
478 })?;
479
480 let url = match crate::registry::public(registry) {
481 Some(pr) => pr.archive_url(pkg_name, version),
482 None => format!(
483 "{}/v1/pkg/{}/{}/archive",
484 registry.trim_end_matches('/'),
485 pkg_name,
486 version,
487 ),
488 };
489 let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
490 name: pkg_name.to_string(),
491 registry: registry.to_string(),
492 version: version.to_string(),
493 detail: format!("GET {url}: {e}"),
494 })?;
495 if response.status() != 200 {
496 return Err(PackageError::RegistryFailed {
497 name: pkg_name.to_string(),
498 registry: registry.to_string(),
499 version: version.to_string(),
500 detail: format!("GET {url} returned HTTP {}", response.status()),
501 });
502 }
503
504 let archive_bytes = response
505 .into_body()
506 .read_to_vec()
507 .map_err(|e| PackageError::RegistryFailed {
508 name: pkg_name.to_string(),
509 registry: registry.to_string(),
510 version: version.to_string(),
511 detail: format!("reading response body: {e}"),
512 })?;
513
514 let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
515 let mut ar = tar::Archive::new(gz);
516 ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
517 name: pkg_name.to_string(),
518 registry: registry.to_string(),
519 version: version.to_string(),
520 detail: format!("extracting archive: {e}"),
521 })?;
522
523 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
524 path: pkg_dir.display().to_string(),
525 detail: e.to_string(),
526 })
527}
528
529pub fn packages_cache_root() -> Option<PathBuf> {
535 packages_cache_dir().ok()
536}
537
538fn packages_cache_dir() -> Result<PathBuf, PackageError> {
539 if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
540 return Ok(PathBuf::from(dir));
541 }
542 let home = std::env::var("HOME")
543 .or_else(|_| std::env::var("USERPROFILE"))
544 .map_err(|_| PackageError::Io {
545 path: "~/.lex/packages".into(),
546 detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
547 })?;
548 Ok(PathBuf::from(home).join(".lex").join("packages"))
549}
550
551#[derive(Debug, thiserror::Error)]
554pub enum PackageError {
555 #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
556 NoManifest { reference: String, searched_from: String },
557
558 #[error("failed to parse {path}: {detail}")]
559 ManifestParse { path: String, detail: String },
560
561 #[error("package \"{name}\" not found in {manifest}")]
562 UnknownPackage { name: String, manifest: String },
563
564 #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
565 ModuleNotFound { pkg: String, module: String, pkg_root: String },
566
567 #[error("git clone of {url} failed: {detail}")]
568 GitFailed { url: String, detail: String },
569
570 #[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
571 RegistryFailed { name: String, registry: String, version: String, detail: String },
572
573 #[error("registry dependency \"{name}\" declares constraint \"{constraint}\" but is not in lex.lock — run `lex pkg lock` to pin a version")]
574 UnlockedRegistryDep { name: String, constraint: String },
575
576 #[error("I/O error at {path}: {detail}")]
577 Io { path: String, detail: String },
578}
579
580#[cfg(test)]
581mod floor_tests {
582 use super::{parse_version, satisfies_floor};
583
584 #[test]
585 fn parses_plain_and_v_prefixed() {
586 assert_eq!(parse_version("0.10.18"), Some((0, 10, 18)));
587 assert_eq!(parse_version("v0.10.18"), Some((0, 10, 18)));
588 assert_eq!(parse_version(" 1.2.3 "), Some((1, 2, 3)));
589 }
590
591 #[test]
592 fn refuses_what_it_cannot_read_rather_than_guessing() {
593 for bad in ["nightly", "0.10", "0.10.18.1", "", "0.x.1", "1.2.3-rc1"] {
597 assert_eq!(parse_version(bad), None, "should not parse: {bad}");
598 assert_eq!(satisfies_floor("0.10.18", bad), None);
599 assert_eq!(satisfies_floor(bad, "0.10.18"), None);
600 }
601 }
602
603 #[test]
604 fn compares_by_component_not_lexically() {
605 assert_eq!(satisfies_floor("0.10.11", "0.10.15"), Some(false));
609 assert_eq!(satisfies_floor("0.10.18", "0.10.15"), Some(true));
610 assert_eq!(satisfies_floor("0.10.9", "0.10.15"), Some(false));
611 assert_eq!(satisfies_floor("0.9.20", "0.10.0"), Some(false));
612 }
613
614 #[test]
615 fn an_exactly_met_floor_is_met() {
616 assert_eq!(satisfies_floor("0.10.15", "0.10.15"), Some(true));
617 }
618
619 #[test]
620 fn the_field_is_read_from_a_manifest() {
621 let m: super::Manifest = toml::from_str(
624 "[package]\nname = \"p\"\nversion = \"0.1.0\"\nlex = \"0.10.15\"\n",
625 )
626 .expect("parse");
627 assert_eq!(m.package.unwrap().lex.as_deref(), Some("0.10.15"));
628 }
629
630 #[test]
631 fn a_manifest_without_the_field_still_parses() {
632 let m: super::Manifest =
633 toml::from_str("[package]\nname = \"p\"\nversion = \"0.1.0\"\n").expect("parse");
634 assert_eq!(m.package.unwrap().lex, None);
635 }
636}
637
638#[cfg(test)]
639mod dual_ref_tests {
640 use super::{Dependency, Manifest};
641
642 fn dep<'a>(m: &'a Manifest, name: &str) -> &'a Dependency {
643 m.dependencies.get(name).expect("dep present")
644 }
645
646 #[test]
647 fn a_dep_with_both_git_and_registry_parses_as_both_not_git() {
648 let m: Manifest = toml::from_str(
652 "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
653 [dependencies]\n\
654 lex-schema = { registry = \"vcs.lexlang.org/lex-official/lex-schema\", version = \"^0.9\", git = \"https://github.com/alpibrusl/lex-schema\" }\n",
655 )
656 .expect("parse");
657 let d = dep(&m, "lex-schema");
658 assert!(matches!(d, Dependency::Both { .. }), "got {d:?}");
659 assert_eq!(
660 d.registry_coord(),
661 Some(("vcs.lexlang.org/lex-official/lex-schema", "^0.9")),
662 "vcs primary must be readable"
663 );
664 assert_eq!(
665 d.git_coord(),
666 Some(("https://github.com/alpibrusl/lex-schema", None, None, None)),
667 "git mirror must be readable"
668 );
669 d.validate().expect("valid");
670 }
671
672 #[test]
673 fn bare_git_and_bare_registry_still_parse_to_their_own_variants() {
674 let m: Manifest = toml::from_str(
675 "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
676 [dependencies]\n\
677 g = { git = \"https://example.com/g\", tag = \"v1\" }\n\
678 r = { registry = \"vcs.example/tenant/r\", version = \"^1.0\" }\n",
679 )
680 .expect("parse");
681 assert!(matches!(dep(&m, "g"), Dependency::Git { .. }));
682 assert!(matches!(dep(&m, "r"), Dependency::Registry { .. }));
683 assert_eq!(dep(&m, "g").registry_coord(), None);
685 assert_eq!(dep(&m, "g").git_coord(), Some(("https://example.com/g", None, Some("v1"), None)));
686 assert_eq!(dep(&m, "r").registry_coord(), Some(("vcs.example/tenant/r", "^1.0")));
687 assert_eq!(dep(&m, "r").git_coord(), None);
688 }
689
690 #[test]
691 fn a_dual_dep_rejects_two_git_refs() {
692 let m: Manifest = toml::from_str(
693 "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
694 [dependencies]\n\
695 d = { registry = \"vcs/r\", version = \"1.0.0\", git = \"https://x/g\", branch = \"main\", tag = \"v1\" }\n",
696 )
697 .expect("parse");
698 assert!(dep(&m, "d").validate().is_err(), "two git refs must be rejected");
699 }
700}