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 {
32 pub name: String,
34 pub version: String,
36 pub dependencies: Vec<(String, String)>,
38}
39impl Manifest {
40 pub fn has_dep(&self, n: &str) -> bool {
42 self.dependencies.iter().any(|(d, _)| d == n)
43 }
44}
45
46pub 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
177pub 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 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
217}
218
219#[derive(Clone, Debug)]
220pub struct ResolvedPackage {
222 pub name: String,
224 pub version: String,
226 pub dir: PathBuf,
228 pub hash: String,
230}
231
232pub type Resolver = Rc<dyn Fn(&str, &Path) -> Result<PathBuf, (String, String)>>;
234
235pub struct PackageUniverse {
237 pub root_dir: PathBuf,
239 pub manifest: Manifest,
241 pub packages: Vec<ResolvedPackage>,
243 pub resolver: Resolver,
245 pub diags: Vec<Diag>,
247}
248impl PackageUniverse {
249 pub fn package(&self, n: &str) -> Option<&ResolvedPackage> {
251 self.packages.iter().find(|p| p.name == n)
252 }
253}
254
255pub 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
271pub 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)?; 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 let mut packages: Vec<ResolvedPackage> = vec![];
296 let mut required_by: Vec<(String, String, String)> = vec![]; 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 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
418pub 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}
433pub 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}
439pub 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}