Skip to main content

lex_syntax/
workspace.rs

1//! `lex.toml` manifest parsing and package resolution.
2//!
3//! A `lex.toml` file marks a project root and declares its package
4//! dependencies. Import paths of the form `"pkg-name/module"` are
5//! resolved against this file.
6//!
7//! ## File format
8//!
9//! ```toml
10//! [package]
11//! name = "lex-web"
12//! version = "0.1.0"
13//!
14//! [dependencies]
15//! lex-schema = { path = "../lex-schema" }
16//! # or:
17//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema" }
18//! ```
19//!
20//! ## Module resolution
21//!
22//! `import "lex-schema/validate" as v` splits into `pkg = "lex-schema"`,
23//! `module = "validate"`. The loader:
24//!
25//! 1. Walks up from the importing file to find the nearest `lex.toml`.
26//! 2. Looks up `lex-schema` in `[dependencies]`.
27//! 3. For `path =`: resolves `{dep_path}/src/validate.lex`; falls back to
28//!    `{dep_path}/validate.lex` if `src/` doesn't exist.
29//! 4. For `git =`: clones the repo into `~/.lex/packages/lex-schema/`
30//!    (once; subsequent loads hit the cache), then resolves the same way.
31
32use serde::Deserialize;
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36// ── Manifest types ────────────────────────────────────────────────────────────
37
38#[derive(Debug, Deserialize)]
39pub struct Manifest {
40    pub package: Option<PackageMeta>,
41    #[serde(default)]
42    pub dependencies: HashMap<String, Dependency>,
43}
44
45#[derive(Debug, Deserialize)]
46pub struct PackageMeta {
47    pub name: String,
48    #[serde(default)]
49    pub version: String,
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(untagged)]
54pub enum Dependency {
55    Path { path: String },
56    Git  { git:  String },
57}
58
59impl Manifest {
60    pub fn load(toml_path: &Path) -> Result<Self, String> {
61        let src = std::fs::read_to_string(toml_path)
62            .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
63        toml::from_str(&src)
64            .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
65    }
66}
67
68// ── Discovery ─────────────────────────────────────────────────────────────────
69
70/// Walk up from `start` (a file or directory) looking for `lex.toml`.
71/// Returns `(toml_path, toml_dir)` for the nearest ancestor that has one.
72pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
73    let mut dir = if start.is_dir() {
74        start.to_path_buf()
75    } else {
76        start.parent()?.to_path_buf()
77    };
78    loop {
79        let candidate = dir.join("lex.toml");
80        if candidate.exists() {
81            return Some((candidate, dir));
82        }
83        match dir.parent() {
84            Some(p) if p != dir => dir = p.to_path_buf(),
85            _ => return None,
86        }
87    }
88}
89
90// ── Resolution ────────────────────────────────────────────────────────────────
91
92/// Resolve `pkg_name/module_path` to a `.lex` file on disk.
93///
94/// `importer` is the file that contains the import statement; it's used
95/// to locate the nearest `lex.toml`.
96pub fn resolve_package_import(
97    importer: &Path,
98    pkg_name: &str,
99    module_path: &str,
100) -> Result<PathBuf, PackageError> {
101    let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
102        PackageError::NoManifest {
103            reference: format!("{pkg_name}/{module_path}"),
104            searched_from: importer.display().to_string(),
105        }
106    })?;
107
108    let manifest = Manifest::load(&toml_path)
109        .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
110
111    let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
112        PackageError::UnknownPackage {
113            name: pkg_name.to_string(),
114            manifest: toml_path.display().to_string(),
115        }
116    })?;
117
118    let pkg_root = match dep {
119        Dependency::Path { path } => {
120            let raw = toml_dir.join(path);
121            raw.canonicalize().map_err(|e| PackageError::Io {
122                path: raw.display().to_string(),
123                detail: e.to_string(),
124            })?
125        }
126        Dependency::Git { git } => git_ensure_cached(pkg_name, git)?,
127    };
128
129    find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
130        pkg: pkg_name.to_string(),
131        module: module_path.to_string(),
132        pkg_root: pkg_root.display().to_string(),
133    })
134}
135
136/// Look for `{module_path}.lex` inside a package root, checking `src/`
137/// first then the root itself.
138fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
139    let rel = PathBuf::from(module_path).with_extension("lex");
140    let in_src = pkg_root.join("src").join(&rel);
141    if in_src.exists() {
142        return Some(in_src);
143    }
144    let at_root = pkg_root.join(&rel);
145    if at_root.exists() {
146        return Some(at_root);
147    }
148    None
149}
150
151// ── Git cache ─────────────────────────────────────────────────────────────────
152
153/// Return the local cache directory for `pkg_name`, cloning from `url`
154/// if it isn't there yet.
155///
156/// Cache root: `$LEX_PACKAGES_DIR` if set, otherwise `~/.lex/packages/`.
157fn git_ensure_cached(pkg_name: &str, url: &str) -> Result<PathBuf, PackageError> {
158    let cache_root = packages_cache_dir()?;
159    let pkg_dir = cache_root.join(pkg_name);
160    if pkg_dir.exists() {
161        return Ok(pkg_dir);
162    }
163    std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
164        path: cache_root.display().to_string(),
165        detail: e.to_string(),
166    })?;
167    let status = std::process::Command::new("git")
168        .args(["clone", "--depth=1", url, pkg_dir.to_str().unwrap_or(pkg_name)])
169        .status()
170        .map_err(|e| PackageError::GitFailed {
171            url: url.to_string(),
172            detail: format!("could not run `git`: {e}"),
173        })?;
174    if !status.success() {
175        return Err(PackageError::GitFailed {
176            url: url.to_string(),
177            detail: format!("`git clone` exited with {status}"),
178        });
179    }
180    pkg_dir.canonicalize().map_err(|e| PackageError::Io {
181        path: pkg_dir.display().to_string(),
182        detail: e.to_string(),
183    })
184}
185
186fn packages_cache_dir() -> Result<PathBuf, PackageError> {
187    if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
188        return Ok(PathBuf::from(dir));
189    }
190    let home = std::env::var("HOME")
191        .or_else(|_| std::env::var("USERPROFILE"))
192        .map_err(|_| PackageError::Io {
193            path: "~/.lex/packages".into(),
194            detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
195        })?;
196    Ok(PathBuf::from(home).join(".lex").join("packages"))
197}
198
199// ── Errors ────────────────────────────────────────────────────────────────────
200
201#[derive(Debug, thiserror::Error)]
202pub enum PackageError {
203    #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
204    NoManifest { reference: String, searched_from: String },
205
206    #[error("failed to parse {path}: {detail}")]
207    ManifestParse { path: String, detail: String },
208
209    #[error("package \"{name}\" not found in {manifest}")]
210    UnknownPackage { name: String, manifest: String },
211
212    #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
213    ModuleNotFound { pkg: String, module: String, pkg_root: String },
214
215    #[error("git clone of {url} failed: {detail}")]
216    GitFailed { url: String, detail: String },
217
218    #[error("I/O error at {path}: {detail}")]
219    Io { path: String, detail: String },
220}