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//! # pin to a tag:
17//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", tag = "v1.2.0" }
18//! # pin to a commit:
19//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", rev = "abc1234" }
20//! # track a branch:
21//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", branch = "stable" }
22//! # or from a registry:
23//! lex-schema = { registry = "https://lexhub.alpibru.com", version = "0.9.2" }
24//! ```
25//!
26//! At most one of `tag`, `rev`, `branch` may be set; omitting all three
27//! clones the default branch at HEAD (not reproducible — pin for releases).
28//!
29//! ## Module resolution
30//!
31//! `import "lex-schema/validate" as v` splits into `pkg = "lex-schema"`,
32//! `module = "validate"`. The loader:
33//!
34//! 1. Walks up from the importing file to find the nearest `lex.toml`.
35//! 2. Looks up `lex-schema` in `[dependencies]`.
36//! 3. For `path =`: resolves `{dep_path}/src/validate.lex`; falls back to
37//!    `{dep_path}/validate.lex` if `src/` doesn't exist.
38//! 4. For `git =`: clones the repo into `~/.lex/packages/lex-schema-<ref>/`
39//!    (once; subsequent loads hit the cache), then resolves the same way.
40
41use serde::Deserialize;
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45// ── Manifest types ────────────────────────────────────────────────────────────
46
47#[derive(Debug, Deserialize)]
48pub struct Manifest {
49    pub package: Option<PackageMeta>,
50    #[serde(default)]
51    pub dependencies: HashMap<String, Dependency>,
52    /// `[store]` — where store-touching commands (`lex branch`,
53    /// `publish`, `merge`, `op`, `store`, ...) keep this project's
54    /// stages, branches, and traces when no `--store` is given (#772).
55    #[serde(default)]
56    pub store: Option<StoreSection>,
57}
58
59/// `[store]` table of `lex.toml`.
60#[derive(Debug, Default, Deserialize)]
61pub struct StoreSection {
62    /// Store root, relative to the manifest's directory (an absolute
63    /// path is used as is). Defaults to `.lex/store` when absent.
64    #[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    /// Default registry URL for `lex pkg publish` when `--registry` is not supplied.
76    #[serde(default)]
77    pub registry: Option<String>,
78}
79
80#[derive(Debug, Deserialize)]
81#[serde(untagged)]
82pub enum Dependency {
83    Path     { path: String },
84    Git      {
85        git:    String,
86        #[serde(default)]
87        branch: Option<String>,
88        #[serde(default)]
89        tag:    Option<String>,
90        #[serde(default)]
91        rev:    Option<String>,
92    },
93    Registry { registry: String, version: String },
94}
95
96impl Dependency {
97    /// Return an error if more than one of branch/tag/rev is set.
98    pub fn validate(&self) -> Result<(), String> {
99        if let Dependency::Git { branch, tag, rev, .. } = self {
100            let count = [branch, tag, rev].iter().filter(|o| o.is_some()).count();
101            if count > 1 {
102                return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
103            }
104        }
105        Ok(())
106    }
107}
108
109impl Manifest {
110    pub fn load(toml_path: &Path) -> Result<Self, String> {
111        let src = std::fs::read_to_string(toml_path)
112            .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
113        toml::from_str(&src)
114            .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
115    }
116}
117
118// ── Discovery ─────────────────────────────────────────────────────────────────
119
120/// Walk up from `start` (a file or directory) looking for `lex.toml`.
121/// Returns `(toml_path, toml_dir)` for the nearest ancestor that has one.
122pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
123    let mut dir = if start.is_dir() {
124        start.to_path_buf()
125    } else {
126        start.parent()?.to_path_buf()
127    };
128    loop {
129        let candidate = dir.join("lex.toml");
130        if candidate.exists() {
131            return Some((candidate, dir));
132        }
133        match dir.parent() {
134            Some(p) if p != dir => dir = p.to_path_buf(),
135            _ => return None,
136        }
137    }
138}
139
140// ── Resolution ────────────────────────────────────────────────────────────────
141
142/// Resolve `pkg_name/module_path` to a `.lex` file on disk.
143///
144/// `importer` is the file that contains the import statement; it's used
145/// to locate the nearest `lex.toml`.
146pub fn resolve_package_import(
147    importer: &Path,
148    pkg_name: &str,
149    module_path: &str,
150) -> Result<PathBuf, PackageError> {
151    let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
152        PackageError::NoManifest {
153            reference: format!("{pkg_name}/{module_path}"),
154            searched_from: importer.display().to_string(),
155        }
156    })?;
157
158    let manifest = Manifest::load(&toml_path)
159        .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
160
161    let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
162        PackageError::UnknownPackage {
163            name: pkg_name.to_string(),
164            manifest: toml_path.display().to_string(),
165        }
166    })?;
167
168    let pkg_root = match dep {
169        Dependency::Path { path } => {
170            let raw = toml_dir.join(path);
171            raw.canonicalize().map_err(|e| PackageError::Io {
172                path: raw.display().to_string(),
173                detail: e.to_string(),
174            })?
175        }
176        Dependency::Git { git, branch, tag, rev } => {
177            dep.validate().map_err(|e| PackageError::ManifestParse {
178                path: toml_path.display().to_string(),
179                detail: e,
180            })?;
181            let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
182            git_ensure_cached(pkg_name, git, &git_ref)?
183        }
184        Dependency::Registry { registry, version } => {
185            registry_ensure_cached(pkg_name, registry, version)?
186        }
187    };
188
189    find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
190        pkg: pkg_name.to_string(),
191        module: module_path.to_string(),
192        pkg_root: pkg_root.display().to_string(),
193    })
194}
195
196/// Look for `{module_path}.lex` inside a package root, checking `src/`
197/// first then the root itself.
198fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
199    let rel = PathBuf::from(module_path).with_extension("lex");
200    let in_src = pkg_root.join("src").join(&rel);
201    if in_src.exists() {
202        return Some(in_src);
203    }
204    let at_root = pkg_root.join(&rel);
205    if at_root.exists() {
206        return Some(at_root);
207    }
208    None
209}
210
211// ── Git cache ─────────────────────────────────────────────────────────────────
212
213/// Parsed ref from a git dependency declaration.
214#[derive(Debug)]
215enum GitRef<'a> {
216    Branch(&'a str),
217    Tag(&'a str),
218    Rev(&'a str),
219    DefaultBranch,
220}
221
222impl<'a> GitRef<'a> {
223    fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
224        if let Some(b) = branch { return GitRef::Branch(b); }
225        if let Some(t) = tag    { return GitRef::Tag(t); }
226        if let Some(r) = rev    { return GitRef::Rev(r); }
227        GitRef::DefaultBranch
228    }
229
230    /// Slug appended to the cache directory name to prevent collisions between
231    /// different refs of the same repo.
232    fn cache_slug(&self) -> String {
233        match self {
234            GitRef::Branch(b)    => format!("@branch-{}", sanitize_ref(b)),
235            GitRef::Tag(t)       => format!("@tag-{}", sanitize_ref(t)),
236            GitRef::Rev(r)       => format!("@rev-{}", &r[..r.len().min(12)]),
237            GitRef::DefaultBranch => String::new(),
238        }
239    }
240}
241
242/// Replace characters that are not safe in directory names.
243fn sanitize_ref(r: &str) -> String {
244    r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
245}
246
247/// Return the local cache directory for `pkg_name`, cloning from `url`
248/// at the given ref if it isn't there yet.
249///
250/// Cache root: `$LEX_PACKAGES_DIR` if set, otherwise `~/.lex/packages/`.
251/// Cache key:  `{pkg_name}{ref_slug}` so different tags/revs don't collide.
252fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
253    let cache_root = packages_cache_dir()?;
254    let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
255    let pkg_dir = cache_root.join(&dir_name);
256    if pkg_dir.exists() {
257        return Ok(pkg_dir);
258    }
259    std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
260        path: cache_root.display().to_string(),
261        detail: e.to_string(),
262    })?;
263
264    let dest = pkg_dir.to_str().unwrap_or(&dir_name);
265
266    let status = match git_ref {
267        GitRef::Rev(rev) => {
268            // Shallow clone is not possible for arbitrary commits; do a full
269            // clone then check out the specific revision.
270            let s = run_git(&["clone", "--quiet", url, dest], url)?;
271            if s {
272                run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
273                true
274            } else {
275                false
276            }
277        }
278        GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
279        GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
280        GitRef::DefaultBranch  => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
281    };
282
283    if !status {
284        // Clean up partial clone so a retry doesn't hit the cache check above.
285        let _ = std::fs::remove_dir_all(&pkg_dir);
286        return Err(PackageError::GitFailed {
287            url: url.to_string(),
288            detail: "`git` exited with non-zero status".into(),
289        });
290    }
291
292    pkg_dir.canonicalize().map_err(|e| PackageError::Io {
293        path: pkg_dir.display().to_string(),
294        detail: e.to_string(),
295    })
296}
297
298/// Run a git command and return `Ok(true)` on success, `Ok(false)` on non-zero
299/// exit, or `Err` if git could not be spawned.
300fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
301    let status = std::process::Command::new("git")
302        .args(args)
303        .status()
304        .map_err(|e| PackageError::GitFailed {
305            url: url.to_string(),
306            detail: format!("could not run `git`: {e}"),
307        })?;
308    Ok(status.success())
309}
310
311/// Download a registry package archive and extract it to the local cache.
312///
313/// Cache path: `$LEX_PACKAGES_DIR/{name}-{version}/` (versioned to avoid
314/// collisions with git-cached packages at `{name}/`).
315///
316/// Download URL: `{registry}/v1/pkg/{name}/{version}/archive`
317fn registry_ensure_cached(
318    pkg_name: &str,
319    registry: &str,
320    version: &str,
321) -> Result<PathBuf, PackageError> {
322    let cache_root = packages_cache_dir()?;
323    // Registry packages are cached under `{name}-{version}` to keep multiple
324    // versions side-by-side and separate from git-cached directories.
325    let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
326    if pkg_dir.exists() {
327        return Ok(pkg_dir);
328    }
329    std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
330        path: cache_root.display().to_string(),
331        detail: e.to_string(),
332    })?;
333
334    let url = format!(
335        "{}/v1/pkg/{}/{}/archive",
336        registry.trim_end_matches('/'),
337        pkg_name,
338        version,
339    );
340    let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
341        name: pkg_name.to_string(),
342        registry: registry.to_string(),
343        version: version.to_string(),
344        detail: format!("GET {url}: {e}"),
345    })?;
346    if response.status() != 200 {
347        return Err(PackageError::RegistryFailed {
348            name: pkg_name.to_string(),
349            registry: registry.to_string(),
350            version: version.to_string(),
351            detail: format!("GET {url} returned HTTP {}", response.status()),
352        });
353    }
354
355    let archive_bytes = response
356        .into_body()
357        .read_to_vec()
358        .map_err(|e| PackageError::RegistryFailed {
359            name: pkg_name.to_string(),
360            registry: registry.to_string(),
361            version: version.to_string(),
362            detail: format!("reading response body: {e}"),
363        })?;
364
365    let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
366    let mut ar = tar::Archive::new(gz);
367    ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
368        name: pkg_name.to_string(),
369        registry: registry.to_string(),
370        version: version.to_string(),
371        detail: format!("extracting archive: {e}"),
372    })?;
373
374    pkg_dir.canonicalize().map_err(|e| PackageError::Io {
375        path: pkg_dir.display().to_string(),
376        detail: e.to_string(),
377    })
378}
379
380/// The flat package cache root used by the resolver — `$LEX_PACKAGES_DIR`
381/// if set, otherwise `~/.lex/packages/`. Returns `None` only when neither is
382/// available. Exposed so tooling can recognise dependencies whose path
383/// resolves *into* this cache (and are therefore aliases of an
384/// already-installed package rather than independent sources).
385pub fn packages_cache_root() -> Option<PathBuf> {
386    packages_cache_dir().ok()
387}
388
389fn packages_cache_dir() -> Result<PathBuf, PackageError> {
390    if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
391        return Ok(PathBuf::from(dir));
392    }
393    let home = std::env::var("HOME")
394        .or_else(|_| std::env::var("USERPROFILE"))
395        .map_err(|_| PackageError::Io {
396            path: "~/.lex/packages".into(),
397            detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
398        })?;
399    Ok(PathBuf::from(home).join(".lex").join("packages"))
400}
401
402// ── Errors ────────────────────────────────────────────────────────────────────
403
404#[derive(Debug, thiserror::Error)]
405pub enum PackageError {
406    #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
407    NoManifest { reference: String, searched_from: String },
408
409    #[error("failed to parse {path}: {detail}")]
410    ManifestParse { path: String, detail: String },
411
412    #[error("package \"{name}\" not found in {manifest}")]
413    UnknownPackage { name: String, manifest: String },
414
415    #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
416    ModuleNotFound { pkg: String, module: String, pkg_root: String },
417
418    #[error("git clone of {url} failed: {detail}")]
419    GitFailed { url: String, detail: String },
420
421    #[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
422    RegistryFailed { name: String, registry: String, version: String, detail: String },
423
424    #[error("I/O error at {path}: {detail}")]
425    Io { path: String, detail: String },
426}