1use 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 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
170pub 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 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 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
236pub 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)?; 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 let mut packages: Vec<ResolvedPackage> = vec![];
274 let mut required_by: Vec<(String, String, String)> = vec![]; 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 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
396pub 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}
415pub 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}