1use std::collections::BTreeMap;
64use std::path::PathBuf;
65
66use serde::Deserialize;
67
68pub mod addon;
69pub mod language;
70pub mod link_kit;
71
72#[cfg(feature = "loader")]
73mod loader;
74#[cfg(feature = "loader")]
75pub use loader::{
76 InitEnv, LoadedKind, LoadedModule, LoadedModules, ModuleFailure, PortablePlugins, engine_dir,
77 load_modules,
78};
79
80pub const PLUGIN_ENTRY_SYMBOL: &str = "lumen_plugin_v1";
84
85pub const PROBE_PREFIX: &str = "lumen_module_probe_";
89pub const INSTALL_PREFIX: &str = "lumen_module_install_";
92pub const REGISTER_PREFIX: &str = "lumen_module_register_";
96
97pub fn entry_symbol(prefix: &str, name: &str) -> String {
107 let mut symbol = String::with_capacity(prefix.len() + name.len());
108 symbol.push_str(prefix);
109 symbol.extend(
110 name.chars()
111 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }),
112 );
113 symbol
114}
115
116#[derive(Debug, Clone, Default)]
127pub struct ResolvedModules(pub BTreeMap<String, Result<PathBuf, String>>);
128
129pub use lumen_plugin_abi::config::library_spellings;
134
135pub const RESERVED_TAGS: &[&str] = &[
146 "a",
147 "button",
148 "checkbox",
149 "column",
150 "date-picker",
151 "dialog",
152 "div",
153 "dropdown",
154 "for",
155 "if",
156 "image",
157 "input",
158 "label",
159 "menu",
160 "menubar",
161 "menuitem",
162 "option",
163 "overlay",
164 "progress",
165 "radio",
166 "root",
167 "row",
168 "script",
169 "scroll",
170 "separator",
171 "slider",
172 "slot",
173 "spacer",
174 "switch",
175 "tab",
176 "tabs",
177 "template",
178 "textarea",
179 "tile",
180 "time-picker",
181 "title-bar",
182 "toggle",
183 "tooltip",
184];
185
186#[derive(Debug, Clone, Default, PartialEq)]
189pub struct DependenciesCfg(pub Vec<DepCfg>);
190
191impl DependenciesCfg {
192 pub fn declared_tags(&self) -> Vec<&str> {
198 let mut tags: Vec<&str> = Vec::new();
199 for dep in &self.0 {
200 for tag in &dep.tags {
201 let tag = tag.as_str();
202 if !tags.contains(&tag) {
203 tags.push(tag);
204 }
205 }
206 }
207 tags
208 }
209}
210
211impl DependenciesCfg {
212 #[must_use]
219 pub fn merged(&self, overlay: &DependenciesCfg) -> DependenciesCfg {
220 let mut deps: Vec<DepCfg> = self
221 .0
222 .iter()
223 .filter(|dep| !overlay.0.iter().any(|o| o.name == dep.name))
224 .cloned()
225 .collect();
226 deps.extend(overlay.0.iter().cloned());
227 deps.sort_by(|a, b| a.name.cmp(&b.name));
228 DependenciesCfg(deps)
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
238pub enum Target {
239 Web,
241 Desktop,
243}
244
245impl Target {
246 pub const ALL: [Target; 2] = [Target::Web, Target::Desktop];
248
249 pub fn name(self) -> &'static str {
251 match self {
252 Target::Web => "web",
253 Target::Desktop => "desktop",
254 }
255 }
256
257 pub fn cfg_flags(self) -> &'static [&'static str] {
264 match self {
265 Target::Web => &["web"],
266 Target::Desktop => &["desktop"],
267 }
268 }
269}
270
271impl std::fmt::Display for Target {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 f.write_str(self.name())
274 }
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
279#[serde(default, deny_unknown_fields)]
280pub struct TargetTables {
281 pub web: TargetCfg,
283 pub desktop: TargetCfg,
285}
286
287impl TargetTables {
288 pub fn get(&self, target: Target) -> &TargetCfg {
290 match target {
291 Target::Web => &self.web,
292 Target::Desktop => &self.desktop,
293 }
294 }
295
296 pub fn dependencies_for(&self, base: &DependenciesCfg, target: Target) -> DependenciesCfg {
299 base.merged(&self.get(target).dependencies)
300 }
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
305#[serde(default, deny_unknown_fields)]
306pub struct TargetCfg {
307 pub dependencies: DependenciesCfg,
310}
311
312pub fn register_declared_tags(deps: &DependenciesCfg) {
331 for tag in deps.declared_tags() {
332 lumen_widget::register_widget_tag_owned(tag);
333 }
334}
335
336pub fn register_addon_elements(addons: &[lumen_ir::addon::Addon]) {
341 for element in addons.iter().flat_map(|addon| &addon.elements) {
342 lumen_widget::register_widget_tag_owned(&element.tag);
343 }
344}
345
346fn validate_tag(module: &str, tag: &str) -> Result<(), String> {
348 if tag.is_empty() {
349 return Err(format!(
350 "dependency '{module}': a tag name must not be empty"
351 ));
352 }
353 if !tag
354 .chars()
355 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
356 {
357 return Err(format!(
358 "dependency '{module}': tag '{tag}' must be lowercase letters, digits, and dashes"
359 ));
360 }
361 if RESERVED_TAGS.contains(&tag) {
362 return Err(format!(
363 "dependency '{module}': tag '{tag}' is a built-in tag and cannot be declared by a \
364 module"
365 ));
366 }
367 Ok(())
368}
369
370#[derive(Debug, Clone, PartialEq)]
372pub struct DepCfg {
373 pub name: String,
375 pub source: ModuleSource,
377 pub config: toml::Table,
379 pub tags: Vec<String>,
387}
388
389#[derive(Debug, Clone, PartialEq)]
392pub enum ModuleSource {
393 Bundled,
395 Version(String),
399 Path(String),
404}
405
406impl<'de> Deserialize<'de> for DependenciesCfg {
407 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
408 where
409 D: serde::Deserializer<'de>,
410 {
411 let raw: BTreeMap<String, toml::Value> = BTreeMap::deserialize(deserializer)?;
414 let mut deps = Vec::with_capacity(raw.len());
415 for (name, value) in raw {
416 deps.push(DepCfg::from_entry(name, value).map_err(serde::de::Error::custom)?);
417 }
418 Ok(DependenciesCfg(deps))
419 }
420}
421
422impl DepCfg {
423 fn from_entry(name: String, value: toml::Value) -> Result<DepCfg, String> {
425 if name.trim().is_empty() {
426 return Err("dependencies: a module name must not be empty".to_string());
427 }
428 let table = match value {
429 toml::Value::String(version) => {
430 return Ok(DepCfg {
431 name,
432 source: ModuleSource::Version(version),
433 config: toml::Table::new(),
434 tags: Vec::new(),
435 });
436 }
437 toml::Value::Table(t) => t,
438 other => {
439 return Err(format!(
440 "dependency '{name}': expected a version string or a table, got {other}"
441 ));
442 }
443 };
444
445 let mut bundled: Option<bool> = None;
446 let mut version: Option<String> = None;
447 let mut path: Option<String> = None;
448 let mut config = toml::Table::new();
449 let mut tags: Vec<String> = Vec::new();
450 for (key, v) in table {
451 match key.as_str() {
452 "bundled" => match v.as_bool() {
453 Some(b) => bundled = Some(b),
454 None => {
455 return Err(format!("dependency '{name}': `bundled` must be a boolean"));
456 }
457 },
458 "version" => match v.as_str() {
459 Some(s) => version = Some(s.to_string()),
460 None => {
461 return Err(format!("dependency '{name}': `version` must be a string"));
462 }
463 },
464 "path" => match v.as_str() {
465 Some(s) => path = Some(s.to_string()),
466 None => return Err(format!("dependency '{name}': `path` must be a string")),
467 },
468 "config" => match v {
469 toml::Value::Table(t) => config = t,
470 _ => return Err(format!("dependency '{name}': `config` must be a table")),
471 },
472 "tags" => match v {
473 toml::Value::Array(items) => {
474 for item in items {
475 let Some(tag) = item.as_str() else {
476 return Err(format!(
477 "dependency '{name}': `tags` must be an array of strings"
478 ));
479 };
480 validate_tag(&name, tag)?;
481 if !tags.iter().any(|t| t == tag) {
482 tags.push(tag.to_string());
483 }
484 }
485 }
486 _ => {
487 return Err(format!(
488 "dependency '{name}': `tags` must be an array of strings"
489 ));
490 }
491 },
492 "git" | "rev" | "registry" => {
493 return Err(format!(
494 "dependency '{name}': `{key}` sources are not supported yet; use \
495 `bundled`, `version`, or `path`"
496 ));
497 }
498 "permissions" => {
499 return Err(format!(
500 "dependency '{name}': capability declarations are not supported yet; a \
501 module is native code running in the app's process, the same trust \
502 model as [[hooks]]"
503 ));
504 }
505 other => {
506 return Err(format!(
507 "unknown key `{other}` in dependency '{name}': this key needs a newer \
508 toolchain"
509 ));
510 }
511 }
512 }
513
514 if bundled == Some(false) {
515 return Err(format!(
516 "dependency '{name}': `bundled = false` declares nothing; drop the key or set \
517 it to true"
518 ));
519 }
520 let declared: Vec<&str> = [
521 bundled.map(|_| "bundled"),
522 version.as_ref().map(|_| "version"),
523 path.as_ref().map(|_| "path"),
524 ]
525 .into_iter()
526 .flatten()
527 .collect();
528 let source = match (bundled, version, path) {
529 (Some(true), None, None) => ModuleSource::Bundled,
530 (None, Some(v), None) => ModuleSource::Version(v),
531 (None, None, Some(p)) => ModuleSource::Path(p),
532 (None, None, None) => {
533 return Err(format!(
534 "dependency '{name}': a source is required - `bundled`, `version`, or `path`"
535 ));
536 }
537 _ => {
538 return Err(format!(
539 "dependency '{name}': {} are {} sources; declare exactly one",
540 declared.join(" and "),
541 declared.len()
542 ));
543 }
544 };
545 Ok(DepCfg {
546 name,
547 source,
548 config,
549 tags,
550 })
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 fn parse(doc: &str) -> Result<DependenciesCfg, String> {
559 toml::from_str::<DependenciesCfg>(doc).map_err(|e| e.to_string())
560 }
561
562 #[test]
563 fn a_target_prints_as_the_name_lumen_toml_writes() {
564 for target in Target::ALL {
565 assert_eq!(target.to_string(), target.name());
566 assert_eq!(target.cfg_flags(), [target.name()]);
567 }
568 }
569
570 #[test]
571 fn a_bare_string_is_a_version() {
572 let deps = parse("md = \"1.2\"\n").unwrap();
573 assert_eq!(deps.0.len(), 1);
574 assert_eq!(deps.0[0].name, "md");
575 assert_eq!(deps.0[0].source, ModuleSource::Version("1.2".into()));
576 assert!(deps.0[0].config.is_empty());
577 }
578
579 #[test]
580 fn bundled_true_parses() {
581 let deps = parse("fs = { bundled = true }\n").unwrap();
582 assert_eq!(deps.0[0].source, ModuleSource::Bundled);
583 }
584
585 #[test]
586 fn bundled_false_is_refused() {
587 let err = parse("fs = { bundled = false }\n").unwrap_err();
588 assert!(err.contains("declares nothing"), "{err}");
589 }
590
591 #[test]
592 fn path_with_config_parses() {
593 let deps =
594 parse("shape = { path = \"modules/shape\", config = { units = \"mm\" } }\n").unwrap();
595 assert_eq!(deps.0[0].source, ModuleSource::Path("modules/shape".into()));
596 assert_eq!(
597 deps.0[0].config.get("units").and_then(|v| v.as_str()),
598 Some("mm")
599 );
600 }
601
602 #[test]
603 fn permissions_are_refused_with_the_trust_model() {
604 let err = parse("x = { path = \"m\", permissions = [\"fs\"] }\n").unwrap_err();
605 assert!(err.contains("capability declarations"), "{err}");
606 assert!(err.contains("[[hooks]]"), "{err}");
607 }
608
609 #[test]
610 fn reserved_sources_are_refused_with_the_reason() {
611 for key in ["git", "rev", "registry"] {
612 let err = parse(&format!("x = {{ {key} = \"v\" }}\n")).unwrap_err();
613 assert!(err.contains("not supported yet"), "{key}: {err}");
614 }
615 }
616
617 #[test]
618 fn an_unknown_key_names_the_toolchain() {
619 let err = parse("y = { path = \"m\", checksum = \"abc\" }\n").unwrap_err();
620 assert!(
621 err.contains("unknown key `checksum` in dependency 'y'"),
622 "{err}"
623 );
624 assert!(err.contains("needs a newer toolchain"), "{err}");
625 }
626
627 #[test]
628 fn two_sources_are_refused() {
629 let err = parse("x = { path = \"m\", version = \"1\" }\n").unwrap_err();
630 assert!(err.contains("declare exactly one"), "{err}");
631 }
632
633 #[test]
634 fn no_source_is_refused() {
635 let err = parse("x = { config = { a = 1 } }\n").unwrap_err();
636 assert!(err.contains("a source is required"), "{err}");
637 }
638
639 #[test]
640 fn tags_parse_and_deduplicate_in_load_order() {
641 let deps = parse(
642 "zeta = { bundled = true, tags = [\"gauge\", \"gauge\"] }\n\
643 alpha = { bundled = true, tags = [\"spark-line\"] }\n",
644 )
645 .unwrap();
646 assert_eq!(deps.declared_tags(), ["spark-line", "gauge"]);
647 }
648
649 #[test]
650 fn a_dep_without_tags_declares_none() {
651 let deps = parse("md = \"1.2\"\n").unwrap();
652 assert!(deps.0[0].tags.is_empty());
653 assert!(deps.declared_tags().is_empty());
654 }
655
656 #[test]
657 fn tags_must_be_an_array_of_strings() {
658 let err = parse("x = { bundled = true, tags = \"gauge\" }\n").unwrap_err();
659 assert!(err.contains("array of strings"), "{err}");
660 let err = parse("x = { bundled = true, tags = [1] }\n").unwrap_err();
661 assert!(err.contains("array of strings"), "{err}");
662 }
663
664 #[test]
665 fn a_tag_outside_the_allowed_characters_is_refused() {
666 for bad in ["Gauge", "spark_line", "spark line", ""] {
667 let err =
668 parse(&format!("x = {{ bundled = true, tags = [\"{bad}\"] }}\n")).unwrap_err();
669 assert!(
670 err.contains("lowercase letters") || err.contains("must not be empty"),
671 "{bad}: {err}"
672 );
673 }
674 }
675
676 #[test]
677 fn a_built_in_tag_cannot_be_claimed() {
678 let err = parse("x = { bundled = true, tags = [\"button\"] }\n").unwrap_err();
679 assert!(err.contains("built-in tag"), "{err}");
680 }
681
682 fn targets(doc: &str) -> Result<(DependenciesCfg, TargetTables), String> {
683 #[derive(Deserialize)]
684 struct Doc {
685 #[serde(default)]
686 dependencies: DependenciesCfg,
687 #[serde(default)]
688 target: TargetTables,
689 }
690 let doc: Doc = toml::from_str(doc).map_err(|e| e.to_string())?;
691 Ok((doc.dependencies, doc.target))
692 }
693
694 #[test]
695 fn a_target_table_is_laid_over_the_shared_one() {
696 let (base, tables) = targets(
697 "[dependencies]\n\
698 shared = { path = \"a\" }\n\
699 both = { path = \"everywhere\" }\n\
700 [target.web.dependencies]\n\
701 both = { path = \"browser\" }\n\
702 page-only = { path = \"p\" }\n\
703 [target.desktop.dependencies]\n\
704 window-only = { path = \"w\" }\n",
705 )
706 .unwrap();
707 let web = tables.dependencies_for(&base, Target::Web);
708 let names: Vec<&str> = web.0.iter().map(|d| d.name.as_str()).collect();
709 assert_eq!(names, ["both", "page-only", "shared"]);
710 assert_eq!(web.0[0].source, ModuleSource::Path("browser".into()));
711
712 let desktop = tables.dependencies_for(&base, Target::Desktop);
713 let names: Vec<&str> = desktop.0.iter().map(|d| d.name.as_str()).collect();
714 assert_eq!(names, ["both", "shared", "window-only"]);
715 assert_eq!(desktop.0[0].source, ModuleSource::Path("everywhere".into()));
716 }
717
718 #[test]
719 fn a_target_lumen_does_not_build_for_is_refused() {
720 let err = targets("[target.mobile.dependencies]\nx = \"1\"\n").unwrap_err();
721 assert!(err.contains("unknown field `mobile`"), "{err}");
722 }
723
724 #[test]
725 fn each_target_names_itself_as_its_condition() {
726 assert_eq!(Target::Web.cfg_flags(), ["web"]);
727 assert_eq!(Target::Desktop.cfg_flags(), ["desktop"]);
728 for target in Target::ALL {
729 assert!(target.cfg_flags().contains(&target.name()));
730 }
731 }
732
733 #[test]
734 fn load_order_is_sorted_by_name() {
735 let deps = parse("zeta = \"1\"\nalpha = \"1\"\nmid = \"1\"\n").unwrap();
736 let names: Vec<&str> = deps.0.iter().map(|d| d.name.as_str()).collect();
737 assert_eq!(names, ["alpha", "mid", "zeta"]);
738 }
739}