1use crate::kernel::{parse, parse_forms, Form};
7use crate::Runtime;
8use semver::{Version, VersionReq};
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13#[path = "project/npm.rs"]
14mod npm;
15
16const REQUIRED: &[&str] = &[
17 "hara/type",
18 "hara/version",
19 "project/id",
20 "project/version",
21 "project/source-paths",
22 "project/test-paths",
23 "project/extension-paths",
24 "project/capabilities",
25];
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct Project {
29 pub root: PathBuf,
30 pub manifest_path: PathBuf,
31 pub id: String,
32 pub version: Version,
33 pub native: Option<NativeRequirement>,
37 pub release_tag: String,
40 pub source_paths: Vec<PathBuf>,
42 pub test_paths: Vec<PathBuf>,
43 pub extension_paths: Vec<PathBuf>,
44 pub shared_source_paths: Vec<PathBuf>,
45 pub shared_test_paths: Vec<PathBuf>,
46 pub shared_extension_paths: Vec<PathBuf>,
47 pub runtime_profiles: BTreeMap<String, RuntimeProfile>,
48 pub active_runtime: String,
49 pub native_source_paths: Vec<PathBuf>,
50 pub runtime_target_path: Option<PathBuf>,
51 pub maven_dependencies: BTreeMap<String, String>,
52 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
53 pub native_imports: BTreeMap<String, WasmNativeImport>,
54 pub capabilities: Vec<String>,
55 pub artifact_paths: Vec<PathBuf>,
56 pub archive_root: Option<PathBuf>,
57 pub package_workspace: bool,
60 pub package_name: Option<String>,
63 pub package_profile: Option<PathBuf>,
66 pub source_files: Option<Vec<PathBuf>>,
71 pub main: Option<String>,
72 pub default_profile: Option<String>,
73 pub profiles: BTreeMap<String, ProjectProfile>,
74 pub dependencies: BTreeMap<String, String>,
76 pub source_excludes: Vec<PathBuf>,
78 pub shared_dependencies: BTreeMap<String, String>,
79 pub extensions: BTreeMap<String, Form>,
80 pub aliases: BTreeMap<String, Vec<String>>,
83 pub distribution: Option<Distribution>,
85 pub recipe: Option<PathBuf>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct NativeRequirement {
90 pub version: Version,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Distribution {
95 pub launcher: String,
97 pub entry: String,
99}
100
101#[derive(Debug, Clone, PartialEq)]
102pub struct ProjectProfile {
103 pub language: String,
104 pub main: Option<String>,
105 pub options: Form,
106}
107
108#[derive(Debug, Clone, PartialEq, Default)]
109pub struct RuntimeProfile {
110 pub source_paths: Vec<PathBuf>,
111 pub source_excludes: Vec<PathBuf>,
112 pub test_paths: Vec<PathBuf>,
113 pub extension_paths: Vec<PathBuf>,
114 pub native_source_paths: Vec<PathBuf>,
115 pub target_path: Option<PathBuf>,
116 pub hara_dependencies: BTreeMap<String, String>,
117 pub maven_dependencies: BTreeMap<String, String>,
118 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
119 pub native_imports: BTreeMap<String, WasmNativeImport>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct NpmWasmDependency {
124 pub version: Version,
125 pub integrity: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct WasmNativeImport {
130 pub package: String,
131 pub module: PathBuf,
132 pub abi: String,
133}
134
135#[derive(Debug, Clone, PartialEq)]
136pub struct ResolvedRuntimeProfile {
137 pub runtime: String,
138 pub source_paths: Vec<PathBuf>,
139 pub source_excludes: Vec<PathBuf>,
140 pub test_paths: Vec<PathBuf>,
141 pub extension_paths: Vec<PathBuf>,
142 pub native_source_paths: Vec<PathBuf>,
143 pub target_path: Option<PathBuf>,
144 pub hara_dependencies: BTreeMap<String, String>,
145 pub maven_dependencies: BTreeMap<String, String>,
146 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
147 pub native_imports: BTreeMap<String, WasmNativeImport>,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub struct ResolvedProfile {
152 pub name: String,
153 pub language: String,
154 pub main: String,
155 pub options: Form,
156}
157
158impl Project {
159 pub fn resolve_runtime_profile(&self, runtime: &str) -> Result<ResolvedRuntimeProfile, String> {
161 resolve_runtime_profile_values(
162 runtime,
163 &self.shared_source_paths,
164 &self.shared_test_paths,
165 &self.shared_extension_paths,
166 &self.shared_dependencies,
167 &self.runtime_profiles,
168 )
169 }
170
171 pub fn resolve_profile(
174 &self,
175 requested: Option<&str>,
176 ) -> Result<Option<ResolvedProfile>, String> {
177 if self.profiles.is_empty() {
178 if requested.is_some() {
179 return Err("project.edn does not declare :project/profiles".into());
180 }
181 return Ok(None);
182 }
183 let name = requested
184 .map(str::to_owned)
185 .or_else(|| self.default_profile.clone())
186 .ok_or("project.edn requires :project/default-profile or an explicit profile")?;
187 let profile = self
188 .profiles
189 .get(&name)
190 .ok_or_else(|| format!("project.edn has no profile {name:?}"))?;
191 let main = profile
192 .main
193 .clone()
194 .or_else(|| self.main.clone())
195 .ok_or_else(|| format!("project profile {name:?} has no main value"))?;
196 Ok(Some(ResolvedProfile {
197 name,
198 language: profile.language.clone(),
199 main,
200 options: profile.options.clone(),
201 }))
202 }
203}
204
205pub fn discover(start: &Path) -> Result<Project, String> {
206 let initial = if start.is_file() {
207 start
208 .parent()
209 .ok_or_else(|| format!("cannot determine project root for {}", start.display()))?
210 } else {
211 start
212 };
213 let mut current = initial
214 .canonicalize()
215 .unwrap_or_else(|_| initial.to_path_buf());
216 loop {
217 let manifest = current.join("project.edn");
218 if manifest.is_file() {
219 return read(&manifest);
220 }
221 match current.parent() {
222 Some(parent) => current = parent.to_path_buf(),
223 None => return Err(format!("no project.edn found above {}", initial.display())),
224 }
225 }
226}
227
228pub fn read(input: &Path) -> Result<Project, String> {
229 let manifest_path = if input.is_dir() {
230 input.join("project.edn")
231 } else {
232 input.to_path_buf()
233 };
234 let root = manifest_path
235 .parent()
236 .ok_or_else(|| {
237 format!(
238 "cannot determine project root for {}",
239 manifest_path.display()
240 )
241 })?
242 .to_path_buf();
243 let source = fs::read_to_string(&manifest_path)
244 .map_err(|error| format!("cannot read {}: {error}", manifest_path.display()))?;
245 let form = parse(&source).map_err(|error| format!("{}: {error}", manifest_path.display()))?;
246 let entries = map(&form, "project.edn must be an EDN map")?;
247 reject_legacy_runtime_keys(entries)?;
248 for key in REQUIRED {
249 if lookup(entries, key).is_none() {
250 return Err(format!("project.edn missing required key :{key}"));
251 }
252 }
253 if !matches!(lookup(entries, "hara/type"), Some(Form::Keyword(value)) if value == "project") {
254 return Err("project.edn :hara/type must be :project".into());
255 }
256 let id = scalar(
257 lookup(entries, "project/id").unwrap(),
258 "project.edn :project/id",
259 )?;
260 let version_text = string(
261 lookup(entries, "project/version").unwrap(),
262 "project.edn :project/version",
263 )?;
264 let version = Version::parse(&version_text)
265 .map_err(|error| format!("project.edn :project/version is not SemVer: {error}"))?;
266 let native = lookup(entries, "project/native")
267 .map(native_requirement)
268 .transpose()?;
269 if let Some(requirement) = &native {
270 validate_native_requirement(requirement)?;
271 }
272 let release_tag = lookup(entries, "project/release-tag")
273 .map(|value| string(value, "project.edn :project/release-tag"))
274 .transpose()?
275 .unwrap_or_else(|| version.to_string());
276 validate_release_tag(&release_tag)?;
277 let shared_source_paths = paths(
278 lookup(entries, "project/source-paths").unwrap(),
279 "project/source-paths",
280 )?;
281 let shared_test_paths = paths(
282 lookup(entries, "project/test-paths").unwrap(),
283 "project/test-paths",
284 )?;
285 let shared_extension_paths = paths(
286 lookup(entries, "project/extension-paths").unwrap(),
287 "project/extension-paths",
288 )?;
289 let capabilities = capability_set(
290 lookup(entries, "project/capabilities").unwrap(),
291 "project.edn :project/capabilities",
292 )?;
293 let artifact_paths = lookup(entries, "project/artifact-paths")
294 .map(|value| paths(value, "project/artifact-paths"))
295 .transpose()?
296 .unwrap_or_default();
297 let archive_root = lookup(entries, "project/archive-root")
298 .map(|value| {
299 relative_path(
300 &string(value, "project/archive-root")?,
301 "project/archive-root",
302 )
303 })
304 .transpose()?;
305 let package_config = lookup(entries, "project/package")
306 .map(package_config)
307 .transpose()?
308 .unwrap_or_default();
309 let source_files = lookup(entries, "project/source-files")
310 .map(|value| paths(value, "project/source-files"))
311 .transpose()?;
312 let main = lookup(entries, "project/main")
313 .map(|value| scalar(value, "project.edn :project/main"))
314 .transpose()?;
315 let default_profile = lookup(entries, "project/default-profile")
316 .map(|value| identifier(value, "project.edn :project/default-profile"))
317 .transpose()?;
318 let profiles = lookup(entries, "project/profiles")
319 .map(project_profiles)
320 .transpose()?
321 .unwrap_or_default();
322 if let Some(default) = &default_profile {
323 if !profiles.contains_key(default) {
324 return Err(format!(
325 "project.edn :project/default-profile {default:?} is not declared in :project/profiles"
326 ));
327 }
328 }
329 let shared_dependencies = lookup(entries, "project/dependencies")
330 .map(dependencies)
331 .transpose()?
332 .unwrap_or_default();
333 let runtime_profiles = lookup(entries, "project/runtime-profiles")
334 .map(runtime_profiles)
335 .transpose()?
336 .unwrap_or_default();
337 let active = resolve_runtime_profile_values(
338 "rust",
339 &shared_source_paths,
340 &shared_test_paths,
341 &shared_extension_paths,
342 &shared_dependencies,
343 &runtime_profiles,
344 )?;
345 let source_paths = active.source_paths.clone();
346 let source_excludes = active.source_excludes.clone();
347 let test_paths = active.test_paths.clone();
348 let extension_paths = active.extension_paths.clone();
349 let dependencies = active.hara_dependencies.clone();
350 let native_source_paths = active.native_source_paths.clone();
351 let runtime_target_path = active.target_path.clone();
352 let maven_dependencies = active.maven_dependencies.clone();
353 let npm_dependencies = active.npm_dependencies.clone();
354 let native_imports = active.native_imports.clone();
355 let extensions = lookup(entries, "project/extensions")
356 .map(extension_declarations)
357 .transpose()?
358 .unwrap_or_default();
359 let aliases = lookup(entries, "project/aliases")
360 .map(project_aliases)
361 .transpose()?
362 .unwrap_or_default();
363 let distribution = lookup(entries, "project/distribution")
364 .map(project_distribution)
365 .transpose()?;
366 let recipe = lookup(entries, "project/recipe")
367 .map(|value| relative_path(&string(value, "project/recipe")?, "project/recipe"))
368 .transpose()?;
369 if let Some(path) = &recipe {
370 if !root.join(path).is_file() {
371 return Err(format!(
372 "project.edn :project/recipe does not exist: {}",
373 path.display()
374 ));
375 }
376 }
377 Ok(Project {
378 root,
379 manifest_path,
380 id,
381 version,
382 native,
383 release_tag,
384 source_paths,
385 test_paths,
386 extension_paths,
387 shared_source_paths,
388 shared_test_paths,
389 shared_extension_paths,
390 runtime_profiles,
391 active_runtime: "rust".into(),
392 native_source_paths,
393 runtime_target_path,
394 maven_dependencies,
395 npm_dependencies,
396 native_imports,
397 capabilities,
398 artifact_paths,
399 archive_root,
400 package_workspace: package_config.workspace,
401 package_name: package_config.name,
402 package_profile: package_config.profile,
403 source_files,
404 main,
405 default_profile,
406 profiles,
407 dependencies,
408 source_excludes,
409 shared_dependencies,
410 extensions,
411 aliases,
412 distribution,
413 recipe,
414 })
415}
416
417fn native_requirement(form: &Form) -> Result<NativeRequirement, String> {
418 let entries = map(form, "project.edn :project/native must be an EDN map")?;
419 if entries.len() != 1 || lookup(entries, "version").is_none() {
420 return Err("project.edn :project/native requires exactly :version".into());
421 }
422 let version = string(
423 lookup(entries, "version").expect("validated required :version"),
424 "project.edn :project/native :version",
425 )?;
426 let version = Version::parse(&version).map_err(|error| {
427 format!("project.edn :project/native :version must be exact SemVer: {error}")
428 })?;
429 Ok(NativeRequirement { version })
430}
431
432fn validate_native_requirement(requirement: &NativeRequirement) -> Result<(), String> {
433 let host = Version::parse(env!("CARGO_PKG_VERSION"))
434 .expect("the hara-native Cargo package version must be valid SemVer");
435 if requirement.version == host {
436 Ok(())
437 } else {
438 Err(format!(
439 "project.edn requires hara-native {}, but this host is {}",
440 requirement.version, host
441 ))
442 }
443}
444
445fn validate_release_tag(tag: &str) -> Result<(), String> {
446 if tag.is_empty()
447 || tag.starts_with('-')
448 || tag.ends_with('.')
449 || tag.contains("..")
450 || tag.bytes().any(|byte| {
451 byte.is_ascii_whitespace()
452 || byte.is_ascii_control()
453 || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
454 })
455 {
456 return Err("project.edn :project/release-tag is not a valid Git tag name".into());
457 }
458 Ok(())
459}
460
461fn extension_declarations(form: &Form) -> Result<BTreeMap<String, Form>, String> {
462 let Form::Map(entries) = form else {
463 return Err("project.edn :project/extensions must be a map".into());
464 };
465 entries
466 .iter()
467 .map(|(namespace, declaration)| {
468 let namespace = scalar(namespace, "project extension namespace")?;
469 if !matches!(declaration, Form::Map(_)) {
470 return Err(format!(
471 "project extension {namespace} declaration must be a map"
472 ));
473 }
474 Ok((namespace, declaration.clone()))
475 })
476 .collect()
477}
478
479pub fn new_app(destination: &Path, name: &str) -> Result<Project, String> {
480 if !valid_name(name) {
481 return Err(
482 "project name must contain only lowercase letters, numbers, and hyphens".into(),
483 );
484 }
485 if destination.exists() {
486 return Err(format!(
487 "destination already exists: {}",
488 destination.display()
489 ));
490 }
491 let namespace = name.replace('-', "_");
492 fs::create_dir_all(destination.join("src").join(&namespace)).map_err(io)?;
493 fs::create_dir_all(destination.join("test").join(&namespace)).map_err(io)?;
494 fs::create_dir_all(destination.join("extensions")).map_err(io)?;
495 fs::write(destination.join("project.edn"), format!(
496 "{{:hara/type :project\n :hara/version \"1.0.0\"\n :project/id {name}\n :project/version \"0.1.0\"\n :project/source-paths [\"src\"]\n :project/test-paths [\"test\"]\n :project/extension-paths [\"extensions\"]\n :project/main {namespace}.main\n :project/capabilities #{{}}\n :project/dependencies {{}}}}\n"
497 )).map_err(io)?;
498 fs::write(
499 destination.join("workspace.edn"),
500 "{:hara/type :workspace :hara/version \"1.0.0\"}\n",
501 )
502 .map_err(io)?;
503 fs::write(
504 destination.join("src").join(&namespace).join("main.hal"),
505 format!("(ns {namespace}.main)\n\n(defn main []\n \"Hello from {name}\")\n\n(main)\n"),
506 )
507 .map_err(io)?;
508 fs::write(
509 destination
510 .join("test")
511 .join(&namespace)
512 .join("main_test.hal"),
513 format!(
514 "(ns {namespace}.main-test)\n\n[(test-check \"starter project runs\" true true)]\n"
515 ),
516 )
517 .map_err(io)?;
518 read(&destination.join("project.edn"))
519}
520
521pub fn set_dependency(
522 project: &Project,
523 coordinate: &str,
524 version: Option<&str>,
525) -> Result<(), String> {
526 validate_coordinate(coordinate)?;
527 if let Some(version) = version {
528 VersionReq::parse(version)
529 .map_err(|error| format!("invalid dependency range {version}: {error}"))?;
530 }
531 let source = fs::read_to_string(&project.manifest_path).map_err(io)?;
532 let mut form =
533 parse(&source).map_err(|error| format!("{}: {error}", project.manifest_path.display()))?;
534 let entries = map_mut(&mut form, "project.edn must be an EDN map")?;
535 let dependency_index = entries
536 .iter()
537 .position(|(key, _)| key_name(key).as_deref() == Some("project/dependencies"));
538 let dependency_form = dependency_index.map(|index| &mut entries[index].1);
539 let deps = match dependency_form {
540 Some(Form::Map(entries)) => entries,
541 Some(_) => return Err("project.edn :project/dependencies must be an EDN map".into()),
542 None => {
543 entries.push((
544 Form::Keyword("project/dependencies".into()),
545 Form::Map(Vec::new()),
546 ));
547 match &mut entries.last_mut().unwrap().1 {
548 Form::Map(entries) => entries,
549 _ => unreachable!(),
550 }
551 }
552 };
553 if let Some(index) = deps.iter().position(|(key, _)| {
554 scalar(key, "dependency coordinate").ok().as_deref() == Some(coordinate)
555 }) {
556 if let Some(version) = version {
557 deps[index].1 = Form::Map(vec![(
558 Form::Keyword("version".into()),
559 Form::String(version.into()),
560 )]);
561 } else {
562 deps.remove(index);
563 }
564 } else if let Some(version) = version {
565 deps.push((
566 Form::String(coordinate.into()),
567 Form::Map(vec![(
568 Form::Keyword("version".into()),
569 Form::String(version.into()),
570 )]),
571 ));
572 }
573 deps.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string()));
574 fs::write(&project.manifest_path, format!("{form}\n")).map_err(io)
575}
576
577pub fn files_in(root: &Path, paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
578 let mut output = Vec::new();
579 for relative in paths {
580 collect_hal(&root.join(relative), &mut output)?;
581 }
582 output.sort();
583 Ok(output)
584}
585
586#[path = "project/resources.rs"]
587mod resources;
588pub use resources::source_resources;
589pub use resources::{source_catalog, source_catalog_at, source_catalogs, SourceCatalog};
590
591pub fn register_sources(project: &Project, runtime: &mut Runtime) -> Result<(), String> {
593 for (namespace, source) in source_resources(project)? {
594 runtime.register_resource(&namespace, &source);
595 }
596 Ok(())
597}
598
599#[cfg(not(target_arch = "wasm32"))]
602pub fn register_native_imports(project: &Project, runtime: &mut Runtime) -> Result<(), String> {
603 if project.native_imports.is_empty() {
604 Ok(())
605 } else {
606 npm::install(project, runtime)
607 }
608}
609
610pub(crate) fn native_archive_entries(project: &Project) -> Result<Vec<PathBuf>, String> {
611 if project.native_imports.is_empty() {
612 Ok(Vec::new())
613 } else {
614 npm::archive_entries(project)
615 }
616}
617
618pub fn main_file(project: &Project) -> Result<PathBuf, String> {
619 let namespace = project
620 .main
621 .as_ref()
622 .ok_or_else(|| "project.edn is missing :project/main".to_owned())?;
623 let relative = format!("{}.hal", namespace.replace('.', "/").replace('-', "_"));
624 for source in &project.source_paths {
625 let candidate = project.root.join(source).join(&relative);
626 if candidate.is_file() {
627 return Ok(candidate);
628 }
629 }
630 Err(format!(
631 "cannot find :project/main {namespace} in :project/source-paths"
632 ))
633}
634
635fn declared_namespace(source: &str) -> Result<Option<String>, String> {
636 Ok(parse_forms(source)?
637 .into_iter()
638 .find_map(declared_namespace_form))
639}
640
641fn declared_namespace_form(form: Form) -> Option<String> {
642 match form {
643 Form::Metadata(_, value) => declared_namespace_form(*value),
644 Form::List(values) if matches!(values.first(), Some(Form::Symbol(head)) if head == "ns" || head == "ns+") => {
645 match values.get(1) {
646 Some(Form::Symbol(namespace)) if !namespace.contains('/') => {
647 Some(namespace.clone())
648 }
649 _ => None,
650 }
651 }
652 _ => None,
653 }
654}
655
656pub fn sync_lock(project: &Project, mode: LockMode) -> Result<PathBuf, String> {
660 let lock = project.root.join("project.lock.edn");
661 if !project.dependencies.is_empty() {
662 return Err(format!(
663 "project sync requires the reviewed registry client to resolve {} declared dependencies",
664 project.dependencies.len()
665 ));
666 }
667 if !project.npm_dependencies.is_empty() || !project.native_imports.is_empty() {
668 return npm::sync(project, mode, &lock);
669 }
670 match mode {
671 LockMode::Locked | LockMode::Frozen if !lock.is_file() => {
672 return Err(format!(
673 "{} requires an existing project.lock.edn",
674 mode.flag()
675 ));
676 }
677 LockMode::Locked | LockMode::Frozen => validate_empty_lock(&lock)?,
678 LockMode::Default | LockMode::Offline => {
679 fs::write(&lock, "{:lock/format \"0.0.1\" :packages {}}\n")
680 .map_err(|error| format!("cannot write {}: {error}", lock.display()))?;
681 }
682 }
683 Ok(lock)
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
687pub enum LockMode {
688 Default,
689 Offline,
690 Locked,
691 Frozen,
692}
693
694impl LockMode {
695 pub fn flag(self) -> &'static str {
696 match self {
697 Self::Default => "sync",
698 Self::Offline => "--offline",
699 Self::Locked => "--locked",
700 Self::Frozen => "--frozen",
701 }
702 }
703}
704
705fn collect_hal(directory: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
706 if !directory.exists() {
707 return Ok(());
708 }
709 for entry in fs::read_dir(directory).map_err(io)? {
710 let path = entry.map_err(io)?.path();
711 if editor_artifact(&path) {
712 continue;
713 }
714 if path.is_dir() {
715 collect_hal(&path, output)?;
716 } else if path.extension().and_then(|value| value.to_str()) == Some("hal") {
717 output.push(path);
718 }
719 }
720 Ok(())
721}
722
723fn editor_artifact(path: &Path) -> bool {
724 path.file_name()
725 .and_then(|value| value.to_str())
726 .is_some_and(|name| {
727 name.starts_with(".#") || (name.starts_with('#') && name.ends_with('#'))
728 })
729}
730
731fn validate_empty_lock(path: &Path) -> Result<(), String> {
732 let source = fs::read_to_string(path)
733 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
734 let form = parse(&source).map_err(|error| format!("{}: {error}", path.display()))?;
735 let entries = map(&form, "project.lock.edn must be an EDN map")?;
736 if matches!(lookup(entries, "lock/format"), Some(Form::String(version)) if version == "0.0.1")
737 && matches!(lookup(entries, "packages"), Some(Form::Map(entries)) if entries.is_empty())
738 {
739 Ok(())
740 } else {
741 Err(format!(
742 "{} is not a lockfile written by this CLI",
743 path.display()
744 ))
745 }
746}
747
748fn map<'a>(form: &'a Form, message: &str) -> Result<&'a Vec<(Form, Form)>, String> {
749 if let Form::Map(entries) = form {
750 Ok(entries)
751 } else {
752 Err(message.into())
753 }
754}
755fn map_mut<'a>(form: &'a mut Form, message: &str) -> Result<&'a mut Vec<(Form, Form)>, String> {
756 if let Form::Map(entries) = form {
757 Ok(entries)
758 } else {
759 Err(message.into())
760 }
761}
762fn key_name(key: &Form) -> Option<String> {
763 match key {
764 Form::Keyword(value) => Some(value.clone()),
765 _ => None,
766 }
767}
768fn lookup<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
769 entries
770 .iter()
771 .find(|(candidate, _)| key_name(candidate).as_deref() == Some(key))
772 .map(|(_, value)| value)
773}
774fn scalar(form: &Form, label: &str) -> Result<String, String> {
775 match form {
776 Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
777 _ => Err(format!("{label} must be a string or symbol")),
778 }
779}
780fn identifier(form: &Form, label: &str) -> Result<String, String> {
781 match form {
782 Form::Keyword(value) | Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
783 _ => Err(format!("{label} must be a keyword, string, or symbol")),
784 }
785}
786
787fn capability_set(form: &Form, label: &str) -> Result<Vec<String>, String> {
788 let Form::Set(values) = form else {
789 return Err(format!("{label} must be an EDN set"));
790 };
791 let mut output = values
792 .iter()
793 .map(|value| identifier(value, label))
794 .collect::<Result<Vec<_>, _>>()?;
795 output.sort();
796 output.dedup();
797 Ok(output)
798}
799
800fn reject_legacy_runtime_keys(entries: &[(Form, Form)]) -> Result<(), String> {
801 for (key, replacement) in [
802 (
803 "jvm/source-paths",
804 ":project/runtime-profiles :jvm :runtime/native-source-paths",
805 ),
806 (
807 "jvm/dependencies",
808 ":project/runtime-profiles :jvm :runtime/dependencies :maven",
809 ),
810 (
811 "jvm/target-path",
812 ":project/runtime-profiles :jvm :runtime/target-path",
813 ),
814 ] {
815 if lookup(entries, key).is_some() {
816 return Err(format!(
817 "project.edn :{key} is no longer supported; use {replacement}"
818 ));
819 }
820 }
821 Ok(())
822}
823
824fn runtime_profiles(form: &Form) -> Result<BTreeMap<String, RuntimeProfile>, String> {
825 let mut output = BTreeMap::new();
826 for (key, value) in map(
827 form,
828 "project.edn :project/runtime-profiles must be an EDN map",
829 )? {
830 let runtime = identifier(key, "runtime profile name")?;
831 if runtime != "jvm" && runtime != "rust" {
832 return Err(format!("unsupported project runtime profile {runtime:?}"));
833 }
834 let entries = map(value, "runtime profile must be an EDN map")?;
835 let source_paths = lookup(entries, "runtime/source-paths")
836 .map(|value| paths(value, "runtime/source-paths"))
837 .transpose()?
838 .unwrap_or_default();
839 let source_excludes = lookup(entries, "runtime/source-excludes")
840 .map(|value| paths(value, "runtime/source-excludes"))
841 .transpose()?
842 .unwrap_or_default();
843 let test_paths = lookup(entries, "runtime/test-paths")
844 .map(|value| paths(value, "runtime/test-paths"))
845 .transpose()?
846 .unwrap_or_default();
847 let extension_paths = lookup(entries, "runtime/extension-paths")
848 .map(|value| paths(value, "runtime/extension-paths"))
849 .transpose()?
850 .unwrap_or_default();
851 let native_source_paths = lookup(entries, "runtime/native-source-paths")
852 .map(|value| paths(value, "runtime/native-source-paths"))
853 .transpose()?
854 .unwrap_or_default();
855 let target_path = lookup(entries, "runtime/target-path")
856 .map(|value| {
857 relative_path(
858 &string(value, "runtime/target-path")?,
859 "runtime/target-path",
860 )
861 })
862 .transpose()?;
863 let (hara_dependencies, maven_dependencies, npm_dependencies) =
864 match lookup(entries, "runtime/dependencies") {
865 None => (BTreeMap::new(), BTreeMap::new(), BTreeMap::new()),
866 Some(value) => {
867 let groups = map(value, "runtime :runtime/dependencies must be an EDN map")?;
868 let hara = lookup(groups, "hara")
869 .map(dependencies)
870 .transpose()?
871 .unwrap_or_default();
872 let maven = lookup(groups, "maven")
873 .map(maven_dependencies)
874 .transpose()?
875 .unwrap_or_default();
876 let npm = lookup(groups, "npm")
877 .map(npm_wasm_dependencies)
878 .transpose()?
879 .unwrap_or_default();
880 (hara, maven, npm)
881 }
882 };
883 let native_imports = lookup(entries, "runtime/imports")
884 .map(|value| wasm_native_imports(value, &npm_dependencies))
885 .transpose()?
886 .unwrap_or_default();
887 let profile = RuntimeProfile {
888 source_paths,
889 source_excludes,
890 test_paths,
891 extension_paths,
892 native_source_paths,
893 target_path,
894 hara_dependencies,
895 maven_dependencies,
896 npm_dependencies,
897 native_imports,
898 };
899 if output.insert(runtime.clone(), profile).is_some() {
900 return Err(format!("duplicate project runtime profile {runtime:?}"));
901 }
902 }
903 Ok(output)
904}
905
906fn resolve_runtime_profile_values(
907 runtime: &str,
908 shared_source_paths: &[PathBuf],
909 shared_test_paths: &[PathBuf],
910 shared_extension_paths: &[PathBuf],
911 shared_dependencies: &BTreeMap<String, String>,
912 runtime_profiles: &BTreeMap<String, RuntimeProfile>,
913) -> Result<ResolvedRuntimeProfile, String> {
914 if runtime != "jvm" && runtime != "rust" {
915 return Err(format!("unsupported project runtime profile {runtime:?}"));
916 }
917 let profile = runtime_profiles.get(runtime).cloned().unwrap_or_default();
918 let mut hara_dependencies = shared_dependencies.clone();
919 for (coordinate, requirement) in &profile.hara_dependencies {
920 if let Some(shared) = hara_dependencies.get(coordinate) {
921 if shared != requirement {
922 return Err(format!(
923 "conflicting Hara dependency requirements for {coordinate} in :{runtime}: {shared:?} and {requirement:?}"
924 ));
925 }
926 }
927 hara_dependencies.insert(coordinate.clone(), requirement.clone());
928 }
929 let mut source_paths = shared_source_paths.to_vec();
930 source_paths.extend(profile.source_paths.iter().cloned());
931 let mut test_paths = shared_test_paths.to_vec();
932 test_paths.extend(profile.test_paths.iter().cloned());
933 let mut extension_paths = shared_extension_paths.to_vec();
934 extension_paths.extend(profile.extension_paths.iter().cloned());
935 Ok(ResolvedRuntimeProfile {
936 runtime: runtime.into(),
937 source_paths,
938 source_excludes: profile.source_excludes,
939 test_paths,
940 extension_paths,
941 native_source_paths: profile.native_source_paths,
942 target_path: profile.target_path,
943 hara_dependencies,
944 maven_dependencies: profile.maven_dependencies,
945 npm_dependencies: profile.npm_dependencies,
946 native_imports: profile.native_imports,
947 })
948}
949
950fn npm_wasm_dependencies(form: &Form) -> Result<BTreeMap<String, NpmWasmDependency>, String> {
951 map(form, "runtime npm dependencies must be an EDN map")?
952 .iter()
953 .map(|(coordinate, declaration)| {
954 let coordinate = string(coordinate, "npm package name")?;
955 let entries = map(declaration, "npm dependency declaration must be an EDN map")?;
956 for (key, _) in entries {
957 let key = identifier(key, "npm dependency field")?;
958 if key != "version" && key != "integrity" {
959 return Err(format!("unsupported npm dependency field :{key}"));
960 }
961 }
962 let version = string(
963 lookup(entries, "version").ok_or("npm dependency requires :version")?,
964 "npm dependency :version",
965 )?;
966 let version = Version::parse(&version)
967 .map_err(|_| "npm dependency :version must be an exact SemVer")?;
968 let integrity = string(
969 lookup(entries, "integrity").ok_or("npm dependency requires :integrity")?,
970 "npm dependency :integrity",
971 )?;
972 let payload = integrity
973 .strip_prefix("sha512-")
974 .ok_or("npm dependency :integrity must use sha512 SRI")?;
975 if payload.len() < 16
976 || !payload
977 .bytes()
978 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
979 {
980 return Err("npm dependency :integrity contains invalid sha512 SRI data".into());
981 }
982 Ok((coordinate, NpmWasmDependency { version, integrity }))
983 })
984 .collect()
985}
986
987fn wasm_native_imports(
988 form: &Form,
989 dependencies: &BTreeMap<String, NpmWasmDependency>,
990) -> Result<BTreeMap<String, WasmNativeImport>, String> {
991 map(form, "runtime imports must be an EDN map")?
992 .iter()
993 .map(|(logical, declaration)| {
994 let logical = identifier(logical, "runtime import name")?;
995 let entries = map(declaration, "runtime import declaration must be an EDN map")?;
996 for (key, _) in entries {
997 let key = identifier(key, "runtime import field")?;
998 if !matches!(key.as_str(), "package" | "module" | "abi") {
999 return Err(format!("unsupported runtime import field :{key}"));
1000 }
1001 }
1002 let package = string(
1003 lookup(entries, "package").ok_or("runtime import requires :package")?,
1004 "runtime import :package",
1005 )?;
1006 if !dependencies.contains_key(&package) {
1007 return Err(format!(
1008 "runtime import {logical:?} uses undeclared npm package {package:?}"
1009 ));
1010 }
1011 let module = relative_path(
1012 &string(
1013 lookup(entries, "module").ok_or("runtime import requires :module")?,
1014 "runtime import :module",
1015 )?,
1016 "runtime import :module",
1017 )?;
1018 if module.extension().and_then(|value| value.to_str()) != Some("wasm") {
1019 return Err("runtime import :module must select a .wasm file".into());
1020 }
1021 let abi = identifier(
1022 lookup(entries, "abi").ok_or("runtime import requires :abi")?,
1023 "runtime import :abi",
1024 )?;
1025 if abi != "core.v1" {
1026 return Err(format!("runtime import uses unsupported ABI :{abi}"));
1027 }
1028 Ok((
1029 logical,
1030 WasmNativeImport {
1031 package,
1032 module,
1033 abi,
1034 },
1035 ))
1036 })
1037 .collect()
1038}
1039
1040fn maven_dependencies(form: &Form) -> Result<BTreeMap<String, String>, String> {
1041 let mut output = BTreeMap::new();
1042 for (key, value) in map(form, "runtime Maven dependencies must be an EDN map")? {
1043 let coordinate = scalar(key, "Maven dependency coordinate")?;
1044 let mut parts = coordinate.split('/');
1045 if !matches!(
1046 (parts.next(), parts.next(), parts.next()),
1047 (Some(group), Some(artifact), None) if !group.is_empty() && !artifact.is_empty()
1048 ) {
1049 return Err(format!(
1050 "invalid Maven dependency coordinate {coordinate:?}"
1051 ));
1052 }
1053 let declaration = map(value, "Maven dependency declaration must be an EDN map")?;
1054 let version = lookup(declaration, "version")
1055 .ok_or_else(|| format!("Maven dependency {coordinate} is missing :version"))
1056 .and_then(|value| string(value, "Maven dependency :version"))?;
1057 if version.is_empty()
1058 || version
1059 .chars()
1060 .any(|value| matches!(value, '[' | ']' | '(' | ')' | ',' | '*'))
1061 {
1062 return Err(format!(
1063 "Maven dependency {coordinate} requires an exact version"
1064 ));
1065 }
1066 if output.insert(coordinate.clone(), version).is_some() {
1067 return Err(format!("duplicate Maven dependency {coordinate}"));
1068 }
1069 }
1070 Ok(output)
1071}
1072
1073fn project_profiles(form: &Form) -> Result<BTreeMap<String, ProjectProfile>, String> {
1074 let mut output = BTreeMap::new();
1075 for (key, value) in map(form, "project.edn :project/profiles must be an EDN map")? {
1076 let name = identifier(key, "project profile name")?;
1077 let entries = map(value, "project profile must be an EDN map")?;
1078 let language = lookup(entries, "profile/language")
1079 .ok_or_else(|| format!("project profile {name:?} is missing :profile/language"))
1080 .and_then(|value| identifier(value, "profile :profile/language"))?;
1081 let main = lookup(entries, "profile/main")
1082 .map(|value| scalar(value, "profile :profile/main"))
1083 .transpose()?;
1084 let options = lookup(entries, "profile/options")
1085 .cloned()
1086 .unwrap_or_else(|| Form::Map(Vec::new()));
1087 if !matches!(options, Form::Map(_)) {
1088 return Err(format!(
1089 "project profile {name:?} :profile/options must be an EDN map"
1090 ));
1091 }
1092 if output
1093 .insert(
1094 name.clone(),
1095 ProjectProfile {
1096 language,
1097 main,
1098 options,
1099 },
1100 )
1101 .is_some()
1102 {
1103 return Err(format!("duplicate project profile {name:?}"));
1104 }
1105 }
1106 Ok(output)
1107}
1108
1109fn project_aliases(form: &Form) -> Result<BTreeMap<String, Vec<String>>, String> {
1110 let mut output = BTreeMap::new();
1111 for (key, value) in map(form, "project.edn :project/aliases must be an EDN map")? {
1112 let name = identifier(key, "project alias name")?;
1113 if name.is_empty() || name.contains('/') || name.starts_with('-') {
1114 return Err(format!("invalid project alias {name:?}"));
1115 }
1116 let Form::Vector(values) = value else {
1117 return Err(format!(
1118 "project alias {name:?} must be a vector of strings"
1119 ));
1120 };
1121 let argv = values
1122 .iter()
1123 .map(|value| string(value, &format!("project alias {name:?}")))
1124 .collect::<Result<Vec<_>, _>>()?;
1125 if argv.is_empty() || argv.iter().any(|value| value.is_empty()) {
1126 return Err(format!(
1127 "project alias {name:?} must contain command tokens"
1128 ));
1129 }
1130 if output.insert(name.clone(), argv).is_some() {
1131 return Err(format!("duplicate project alias {name:?}"));
1132 }
1133 }
1134 Ok(output)
1135}
1136
1137fn project_distribution(form: &Form) -> Result<Distribution, String> {
1138 let entries = map(form, "project.edn :project/distribution must be an EDN map")?;
1139 let launcher = lookup(entries, "launcher")
1140 .ok_or_else(|| "project.edn :project/distribution requires :launcher".to_owned())
1141 .and_then(|value| string(value, "project.edn :project/distribution :launcher"))?;
1142 if !valid_name(&launcher) {
1143 return Err(
1144 "project.edn :project/distribution :launcher must contain lowercase letters, digits, or hyphens"
1145 .into(),
1146 );
1147 }
1148 let entry = lookup(entries, "entry")
1149 .ok_or("project.edn :project/distribution requires :entry")
1150 .and_then(|value| match value {
1151 Form::Symbol(value) => Ok(value.clone()),
1152 _ => Err("project.edn :project/distribution :entry must be a symbol".into()),
1153 })?;
1154 let valid_entry = entry
1155 .split_once('/')
1156 .is_some_and(|(namespace, symbol)| !namespace.is_empty() && !symbol.is_empty());
1157 if !valid_entry || entry.matches('/').count() != 1 {
1158 return Err("project.edn :project/distribution :entry must name namespace/symbol".into());
1159 }
1160 Ok(Distribution { launcher, entry })
1161}
1162
1163pub fn expand_aliases(project: &Project, argv: &[String]) -> Result<Vec<String>, String> {
1166 let mut output = argv.to_vec();
1167 let mut seen = BTreeMap::new();
1168 loop {
1169 let Some(name) = output.first().cloned() else {
1170 return Ok(output);
1171 };
1172 let Some(prefix) = project.aliases.get(&name) else {
1173 return Ok(output);
1174 };
1175 if seen.insert(name.clone(), true).is_some() {
1176 return Err(format!("project alias cycle detected at {name:?}"));
1177 }
1178 let mut expanded = prefix.clone();
1179 expanded.extend(output.into_iter().skip(1));
1180 output = expanded;
1181 }
1182}
1183fn string(form: &Form, label: &str) -> Result<String, String> {
1184 match form {
1185 Form::String(value) => Ok(value.clone()),
1186 _ => Err(format!("{label} must be a string")),
1187 }
1188}
1189fn relative_path(value: &str, label: &str) -> Result<PathBuf, String> {
1190 let path = PathBuf::from(value);
1191 if path.components().any(|component| {
1192 matches!(
1193 component,
1194 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1195 )
1196 }) {
1197 Err(format!(
1198 "project.edn :{label} cannot escape the project root"
1199 ))
1200 } else {
1201 Ok(path)
1202 }
1203}
1204fn paths(form: &Form, label: &str) -> Result<Vec<PathBuf>, String> {
1205 match form {
1206 Form::Vector(values) => values
1207 .iter()
1208 .map(|value| relative_path(&string(value, &format!("project.edn :{label}"))?, label))
1209 .collect(),
1210 _ => Err(format!("project.edn :{label} must be a vector of strings")),
1211 }
1212}
1213#[derive(Default)]
1214struct PackageConfig {
1215 workspace: bool,
1216 name: Option<String>,
1217 profile: Option<PathBuf>,
1218}
1219
1220fn package_config(form: &Form) -> Result<PackageConfig, String> {
1221 let entries = map(form, "project.edn :project/package must be an EDN map")?;
1222 let workspace = match lookup(entries, "workspace") {
1223 None | Some(Form::Bool(false)) => false,
1224 Some(Form::Bool(true)) => true,
1225 Some(_) => return Err("project.edn :project/package :workspace must be a boolean".into()),
1226 };
1227 let name = lookup(entries, "name")
1228 .map(|value| identifier(value, "project.edn :project/package :name"))
1229 .transpose()?;
1230 if name.as_deref().is_some_and(str::is_empty) {
1231 return Err("project.edn :project/package :name must be non-empty".into());
1232 }
1233 let profile = lookup(entries, "profile")
1234 .map(|value| {
1235 relative_path(
1236 &string(value, "project.edn :project/package :profile")?,
1237 "project/package/profile",
1238 )
1239 })
1240 .transpose()?;
1241 Ok(PackageConfig {
1242 workspace,
1243 name,
1244 profile,
1245 })
1246}
1247fn dependencies(form: &Form) -> Result<BTreeMap<String, String>, String> {
1248 let mut output = BTreeMap::new();
1249 for (key, value) in map(form, "project.edn :project/dependencies must be an EDN map")? {
1250 let coordinate = normalize_coordinate(&scalar(key, "dependency coordinate")?)?;
1251 let version = lookup(
1252 map(value, "dependency declaration must be an EDN map")?,
1253 "version",
1254 )
1255 .ok_or_else(|| format!("dependency {coordinate} is missing :version"))?;
1256 let version = string(version, "dependency :version")?;
1257 VersionReq::parse(&version)
1258 .map_err(|error| format!("invalid dependency range {version}: {error}"))?;
1259 output.insert(coordinate, version);
1260 }
1261 Ok(output)
1262}
1263pub fn normalize_coordinate(value: &str) -> Result<String, String> {
1264 let qualified = if let Some(package) = value.strip_prefix("official:") {
1265 format!("hara:{package}")
1266 } else if value.contains(':') {
1267 value.to_owned()
1268 } else {
1269 format!("hara:{value}")
1270 };
1271 let (tap, package) = qualified
1272 .split_once(':')
1273 .ok_or_else(|| format!("invalid package coordinate: {value}"))?;
1274 let mut parts = package.split('/');
1275 let valid = !tap.is_empty()
1276 && tap.chars().all(valid_coordinate_char)
1277 && matches!((parts.next(), parts.next(), parts.next()), (Some(owner), Some(name), None) if !owner.is_empty() && !name.is_empty() && owner.chars().all(valid_coordinate_char) && name.chars().all(valid_coordinate_char));
1278 if valid {
1279 Ok(qualified)
1280 } else {
1281 Err(format!("invalid package coordinate: {value}"))
1282 }
1283}
1284fn validate_coordinate(value: &str) -> Result<(), String> {
1285 normalize_coordinate(value).map(|_| ())
1286}
1287fn valid_coordinate_char(value: char) -> bool {
1288 value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.')
1289}
1290fn valid_name(value: &str) -> bool {
1291 !value.is_empty()
1292 && value
1293 .chars()
1294 .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')
1295}
1296fn io(error: std::io::Error) -> String {
1297 error.to_string()
1298}
1299
1300#[cfg(test)]
1301#[path = "project/tests.rs"]
1302mod tests;