Skip to main content

decl_lang/
package.rs

1//! Packages, decl.toml, and decl.lock (§8.6–8.7) — a port of the
2//! reference implementation's package.ts: exact-pinned dependencies,
3//! fail-closed manifests, content-hashed reproducibility. Conventions:
4//! dependency packages live under `<root>/decl_modules/<name>/` in a flat
5//! layout, and the lock file is line-based `name version sha256` in name
6//! order.
7use crate::semantics::{json_str, read_json, Diag, Value};
8use regex::Regex;
9use sha2::{Digest, Sha256};
10use std::path::{Path, PathBuf};
11use std::rc::Rc;
12
13const SEMANTIC: [&str; 2] = ["name", "version"];
14const METADATA: [&str; 5] = [
15    "description",
16    "license",
17    "authors",
18    "repository",
19    "keywords",
20];
21
22fn name_re() -> Regex {
23    Regex::new(r"^[a-z][a-z0-9_-]*$").unwrap()
24}
25fn version_re() -> Regex {
26    Regex::new(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$").unwrap()
27}
28
29#[derive(Clone, Debug)]
30/// a `decl.toml` (§8.6)
31pub struct Manifest {
32    /// the package's name
33    pub name: String,
34    /// its version, an exact semantic version
35    pub version: String,
36    /// declaration order, like the reference's Map
37    pub dependencies: Vec<(String, String)>,
38}
39impl Manifest {
40    /// Whether the manifest declares a dependency of that name.
41    pub fn has_dep(&self, n: &str) -> bool {
42        self.dependencies.iter().any(|(d, _)| d == n)
43    }
44}
45
46/// Read a manifest, fail-closed (D28): an unknown field is E3011, a range pin
47/// E3012, a missing file E3004 — each reported through `report` with the code
48/// and the message. `None` when the manifest cannot be used.
49pub fn parse_manifest(path: &Path, report: &mut dyn FnMut(&str, String)) -> Option<Manifest> {
50    let shown = path.display().to_string();
51    let Ok(src) = std::fs::read_to_string(path) else {
52        report("E3004", format!("manifest not found: {shown}"));
53        return None;
54    };
55    let (name_re, version_re) = (name_re(), version_re());
56    let comment = Regex::new(r"#.*$").unwrap();
57    let sec_re = Regex::new(r"^\[([^\]]+)\]$").unwrap();
58    let kv_re = Regex::new(r"^([A-Za-z0-9_-]+)\s*=\s*(.+)$").unwrap();
59    let mut fields: Vec<(String, String)> = vec![];
60    let mut deps: Vec<(String, String)> = vec![];
61    let mut section: Option<String> = None;
62    let mut ok = true;
63    for line0 in src.split('\n') {
64        let line = comment.replace(line0, "").trim().to_string();
65        if line.is_empty() {
66            continue;
67        }
68        if let Some(sec) = sec_re.captures(&line) {
69            let s = sec[1].to_string();
70            if s != "dependencies" {
71                report(
72                    "E3011",
73                    format!("manifest {shown}: unknown section [{s}] (fail-closed, D28)"),
74                );
75                ok = false;
76            }
77            section = Some(s);
78            continue;
79        }
80        let Some(kv) = kv_re.captures(&line) else {
81            report(
82                "E3011",
83                format!("manifest {shown}: unparseable line \"{line}\""),
84            );
85            ok = false;
86            continue;
87        };
88        let key = kv[1].to_string();
89        let raw = kv[2].trim().to_string();
90        let value: Option<String> = if raw.starts_with('"') {
91            match read_json(&raw.replace('\\', "\\\\")) {
92                Ok(Value::Str(s)) => Some(s),
93                Ok(_) => None,
94                Err(_) => {
95                    report(
96                        "E3011",
97                        format!("manifest {shown}: unparseable line \"{line}\""),
98                    );
99                    ok = false;
100                    continue;
101                }
102            }
103        } else {
104            Some(raw.clone())
105        };
106        match section.as_deref() {
107            Some("dependencies") => {
108                if !name_re.is_match(&key) {
109                    report(
110                        "E3013",
111                        format!("manifest {shown}: invalid package name {key}"),
112                    );
113                    ok = false;
114                    continue;
115                }
116                match &value {
117                    Some(v) if version_re.is_match(v) => deps.push((key, v.clone())),
118                    _ => {
119                        report("E3012", format!("manifest {shown}: dependency {key} = {raw} is not an exact semantic-version pin"));
120                        ok = false;
121                    }
122                }
123            }
124            None => {
125                if !SEMANTIC.contains(&key.as_str()) && !METADATA.contains(&key.as_str()) {
126                    report(
127                        "E3011",
128                        format!("manifest {shown}: unknown field {key} (fail-closed, D28)"),
129                    );
130                    ok = false;
131                    continue;
132                }
133                if let Some(v) = value {
134                    fields.push((key, v));
135                }
136            }
137            _ => {}
138        }
139    }
140    let field = |k: &str| {
141        fields
142            .iter()
143            .rev()
144            .find(|(n, _)| n == k)
145            .map(|(_, v)| v.clone())
146            .unwrap_or_default()
147    };
148    let (name, version) = (field("name"), field("version"));
149    if !name_re.is_match(&name) {
150        report(
151            "E3013",
152            format!("manifest {shown}: invalid package name {}", json_str(&name)),
153        );
154        ok = false;
155    }
156    if !version_re.is_match(&version) {
157        report(
158            "E3012",
159            format!(
160                "manifest {shown}: version {} is not an exact triple",
161                json_str(&version)
162            ),
163        );
164        ok = false;
165    }
166    if ok {
167        Some(Manifest {
168            name,
169            version,
170            dependencies: deps,
171        })
172    } else {
173        None
174    }
175}
176
177/// content hash: SHA-256 over the package's module files in canonical path order (§8.7)
178pub fn package_hash(dir: &Path) -> String {
179    fn walk(d: &Path, out: &mut Vec<PathBuf>) {
180        let Ok(rd) = std::fs::read_dir(d) else { return };
181        let mut names: Vec<String> = rd
182            .flatten()
183            .map(|e| e.file_name().to_string_lossy().to_string())
184            .collect();
185        names.sort();
186        for e in names {
187            let p = d.join(&e);
188            if e == "decl_modules" {
189                continue;
190            }
191            if p.is_dir() {
192                walk(&p, out);
193            } else if e.ends_with(".decl") {
194                out.push(p);
195            }
196        }
197    }
198    let mut files = vec![];
199    walk(dir, &mut files);
200    files.sort();
201    let mut h = Sha256::new();
202    for f in files {
203        let rel = f
204            .strip_prefix(dir)
205            .unwrap_or(&f)
206            .components()
207            .map(|c| c.as_os_str().to_string_lossy().to_string())
208            .collect::<Vec<_>>()
209            .join("/");
210        h.update(rel.as_bytes());
211        h.update(b"\0");
212        h.update(std::fs::read(&f).unwrap_or_default());
213        h.update(b"\0");
214    }
215    // the digest as lowercase hex (sha2 0.11 no longer formats its array)
216    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
217}
218
219#[derive(Clone, Debug)]
220/// a dependency resolved to a directory
221pub struct ResolvedPackage {
222    /// its name
223    pub name: String,
224    /// its version
225    pub version: String,
226    /// where it lives
227    pub dir: PathBuf,
228    /// the content hash of its files, the one the lock records
229    pub hash: String,
230}
231
232/// maps a package specifier, from a directory, to the package's path or to a (code, message) diagnostic
233pub type Resolver = Rc<dyn Fn(&str, &Path) -> Result<PathBuf, (String, String)>>;
234
235/// the closed set of packages an entry file's manifest reaches (§8.6)
236pub struct PackageUniverse {
237    /// the directory holding `decl.toml`
238    pub root_dir: PathBuf,
239    /// that manifest
240    pub manifest: Manifest,
241    /// closed dependency set (root excluded), in resolution order
242    pub packages: Vec<ResolvedPackage>,
243    /// the resolver over the closed set
244    pub resolver: Resolver,
245    /// the manifest and resolution diagnostics
246    pub diags: Vec<Diag>,
247}
248impl PackageUniverse {
249    /// The resolved package of that name, when the closed set has it.
250    pub fn package(&self, n: &str) -> Option<&ResolvedPackage> {
251        self.packages.iter().find(|p| p.name == n)
252    }
253}
254
255/// the enclosing package root (the nearest ancestor with decl.toml)
256pub fn find_package_root(from_file: &Path) -> Option<PathBuf> {
257    let abs = std::path::absolute(from_file).unwrap_or_else(|_| from_file.to_path_buf());
258    let mut dir = abs.parent()?.to_path_buf();
259    loop {
260        if dir.join("decl.toml").exists() {
261            return Some(dir);
262        }
263        let up = dir.parent()?.to_path_buf();
264        if up == dir {
265            return None;
266        }
267        dir = up;
268    }
269}
270
271/// Open the package universe of an entry file: the manifest found upward from
272/// it, its dependencies resolved to a closed set. `None` when no manifest
273/// governs the entry.
274pub fn open_package_universe(entry_file: &Path) -> Option<PackageUniverse> {
275    let mut diags: Vec<Diag> = vec![];
276    let root_dir = find_package_root(entry_file)?; // not in a package: relative imports only
277    let mut report =
278        |code: &str, message: String| diags.push(Diag::error(message, String::new(), Some(code)));
279    let Some(manifest) = parse_manifest(&root_dir.join("decl.toml"), &mut report) else {
280        return Some(PackageUniverse {
281            root_dir,
282            manifest: Manifest {
283                name: "?".into(),
284                version: "0.0.0".into(),
285                dependencies: vec![],
286            },
287            packages: vec![],
288            resolver: Rc::new(|_, _| Err(("E3011".into(), "unusable manifest".into()))),
289            diags,
290        });
291    };
292
293    // resolve the closed dependency set (flat decl_modules layout);
294    // conflicting versions for one package are E3014 against both requirers
295    let mut packages: Vec<ResolvedPackage> = vec![];
296    let mut required_by: Vec<(String, String, String)> = vec![]; // dep, version, by
297    fn visit(
298        m: &Manifest,
299        root_dir: &Path,
300        packages: &mut Vec<ResolvedPackage>,
301        required_by: &mut Vec<(String, String, String)>,
302        report: &mut dyn FnMut(&str, String),
303    ) {
304        for (dep, ver) in &m.dependencies {
305            if let Some((_, pv, pby)) = required_by.iter().find(|(d, _, _)| d == dep) {
306                if pv != ver {
307                    report(
308                        "E3014",
309                        format!(
310                            "package {dep} required at {pv} (by {pby}) and {ver} (by {})",
311                            m.name
312                        ),
313                    );
314                    continue;
315                }
316            }
317            if let Some(e) = required_by.iter_mut().find(|(d, _, _)| d == dep) {
318                *e = (dep.clone(), ver.clone(), m.name.clone());
319            } else {
320                required_by.push((dep.clone(), ver.clone(), m.name.clone()));
321            }
322            if packages.iter().any(|p| &p.name == dep) {
323                continue;
324            }
325            let dir = root_dir.join("decl_modules").join(dep);
326            let Some(dm) = parse_manifest(&dir.join("decl.toml"), report) else {
327                continue;
328            };
329            if &dm.name != dep {
330                report(
331                    "E3013",
332                    format!(
333                        "package at {} names itself {}, expected {dep}",
334                        dir.display(),
335                        dm.name
336                    ),
337                );
338            }
339            if &dm.version != ver {
340                report(
341                    "E3016",
342                    format!(
343                        "package {dep}: manifest version {} differs from required pin {ver}",
344                        dm.version
345                    ),
346                );
347            }
348            packages.push(ResolvedPackage {
349                name: dep.clone(),
350                version: dm.version.clone(),
351                dir: dir.clone(),
352                hash: package_hash(&dir),
353            });
354            visit(&dm, root_dir, packages, required_by, report);
355        }
356    }
357    visit(
358        &manifest,
359        &root_dir,
360        &mut packages,
361        &mut required_by,
362        &mut report,
363    );
364
365    let resolver: Resolver = {
366        let root_dir = root_dir.clone();
367        let manifest = manifest.clone();
368        let packages = packages.clone();
369        Rc::new(move |spec: &str, from_dir: &Path| {
370            let (pkg, rest) = match spec.find('/') {
371                Some(i) => (&spec[..i], &spec[i + 1..]),
372                None => (spec, ""),
373            };
374            // which package does the importing file belong to?
375            let from_abs = std::path::absolute(from_dir).unwrap_or_else(|_| from_dir.to_path_buf());
376            let from_pkg_dir = packages
377                .iter()
378                .find(|p| from_abs.starts_with(&p.dir))
379                .map(|p| p.dir.clone())
380                .unwrap_or_else(|| root_dir.clone());
381            let from_manifest = if from_pkg_dir == root_dir {
382                Some(manifest.clone())
383            } else {
384                parse_manifest(&from_pkg_dir.join("decl.toml"), &mut |_, _| {})
385            };
386            let from_name = from_manifest
387                .as_ref()
388                .map(|m| m.name.clone())
389                .unwrap_or_else(|| "?".into());
390            if !from_manifest.map(|m| m.has_dep(pkg)).unwrap_or(false) {
391                return Err((
392                    "E3010".into(),
393                    format!("package {pkg} not declared in [dependencies] of {from_name}"),
394                ));
395            }
396            let Some(p) = packages.iter().find(|p| p.name == pkg) else {
397                return Err((
398                    "E3004".into(),
399                    format!("package {pkg} could not be resolved"),
400                ));
401            };
402            Ok(if rest.is_empty() {
403                p.dir.clone()
404            } else {
405                p.dir.join(rest)
406            })
407        })
408    };
409    Some(PackageUniverse {
410        root_dir,
411        manifest,
412        packages,
413        resolver,
414        diags,
415    })
416}
417
418// ---------------- decl.lock (§8.7) ----------------
419/// The lock file's text (§8.7): one line per package, `name version hash`, sorted by name.
420pub fn lock_text(u: &PackageUniverse) -> String {
421    let mut ps: Vec<&ResolvedPackage> = u.packages.iter().collect();
422    ps.sort_by(|a, b| a.name.cmp(&b.name));
423    let lines: Vec<String> = ps
424        .iter()
425        .map(|p| format!("{} {} {}", p.name, p.version, p.hash))
426        .collect();
427    if lines.is_empty() {
428        String::new()
429    } else {
430        lines.join("\n") + "\n"
431    }
432}
433/// Write the lock file into the package root; returns its path.
434pub fn write_lock(u: &PackageUniverse) -> PathBuf {
435    let path = u.root_dir.join("decl.lock");
436    let _ = std::fs::write(&path, lock_text(u));
437    path
438}
439/// fail-closed verification: missing entry, version drift, or hash
440/// mismatch stops resolution — never a silent re-resolve
441pub fn verify_lock(u: &PackageUniverse) -> Vec<Diag> {
442    let path = u.root_dir.join("decl.lock");
443    let Ok(text) = std::fs::read_to_string(&path) else {
444        return vec![];
445    };
446    let mut out = vec![];
447    let mut report =
448        |code: &str, message: String| out.push(Diag::error(message, String::new(), Some(code)));
449    let mut locked: Vec<(String, String, String)> = vec![];
450    for line in text.split('\n') {
451        if line.trim().is_empty() {
452            continue;
453        }
454        let parts: Vec<&str> = line.split_whitespace().collect();
455        locked.push((
456            parts[0].to_string(),
457            parts.get(1).unwrap_or(&"").to_string(),
458            parts.get(2).unwrap_or(&"").to_string(),
459        ));
460    }
461    for p in &u.packages {
462        let Some((_, v, h)) = locked.iter().find(|(n, _, _)| *n == p.name) else {
463            report("E3015", format!("lock: missing entry for {}", p.name));
464            continue;
465        };
466        if *v != p.version {
467            report(
468                "E3016",
469                format!(
470                    "lock: {} version {v} differs from manifest {}",
471                    p.name, p.version
472                ),
473            );
474        } else if *h != p.hash {
475            report("E3017", format!("lock: {} content-hash mismatch", p.name));
476        }
477    }
478    out
479}