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