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