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