Skip to main content

lumen_modules/
lib.rs

1//! Runtime dependencies: the `[dependencies]` schema in `lumen.toml`, and
2//! the loader that brings each declared library into the app at startup.
3//!
4//! One table declares two kinds, told apart at load by the symbols the file
5//! exports (see [`loader`](crate::load_modules)):
6//!
7//! - An **engine-locked runtime module** is an ordinary
8//!   [`lumen_core::app::Plugin`] built as a shared library that links the
9//!   engine dynamically (`lumen-dylib`). The loader opens it, verifies it
10//!   against the running engine, and calls its install entry, which
11//!   registers real systems, components, and resources into the same ECS
12//!   worlds the app runs on.
13//! - A **portable plugin** is a C-ABI cdylib (`lumen-plugin`) exchanging
14//!   serialized bytes: script functions, language preludes, and events. It
15//!   needs no dynamic engine and loads into static hosts too.
16//!
17//! A module the binary was built with takes neither path. It has no file to
18//! open, so its constructor put its install entry on the registry before
19//! `main`, and the loader answers the declared name from there before it
20//! looks for anything on disk. That arm works on every platform, needs no
21//! shared engine, and skips both hazards below: the module is this build.
22//!
23//! Two hazards shape the engine-locked arm, and both are checked before any
24//! Rust symbol is touched:
25//!
26//! - **Build skew.** Nothing detects a layout-changed rebuild naturally: the
27//!   dynamic linker resolves happily and `TypeId` equality still passes while
28//!   field reads are shifted. The only defense is the C-ABI probe
29//!   (`lumen_module_probe_<name>`, spelled from the declared name), compared
30//!   against the engine's own `lumen_dylib::BUILD_ID` for exact equality.
31//! - **A second engine instance.** If the host process compiled the engine in
32//!   (a plain `cargo` binary, a static bundle), dlopening a module maps
33//!   `liblumen_engine` a second time; the probe strings would match while
34//!   worlds and statics differ. The loader asks the running process for the
35//!   engine's exported `lumen_engine_build_id` symbol first and refuses every
36//!   module when it is absent.
37//!
38//! The failure policy is banner-and-continue: any load failure prints an
39//! unmissable stderr banner naming the module, the reason, and every probed
40//! path, and the app keeps booting without that module. One refusal speaks
41//! quietly instead: a name that this build neither compiles in nor can open
42//! beside a shared engine gets a single stderr line, because that is a
43//! property of how the binary was put together, not a defect in the app. A
44//! loaded library is never unloaded: the schedules hold
45//! function pointers into it for as long as the app lives.
46//!
47//! ```toml
48//! [dependencies]
49//! lumen-audio = { bundled = true }
50//! markdown-widgets = { version = "1.2", tags = ["markdown"] }
51//! shape-tools = { path = "modules/shape-tools", config = { units = "mm" } }
52//! ```
53//!
54//! The table is unordered, so load order is the sorted key order; declaring
55//! order in the file carries no meaning.
56//!
57//! `tags` is how a module introduces markup when nothing installs it ahead
58//! of the parse. A compile reads a web half's elements from its descriptor
59//! and installs a module with no web half, which registers its own; for any
60//! other module an element the language does not know is refused unless the
61//! app said which module answers for it. See [`register_declared_tags`].
62
63use 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
80/// The C-ABI entry a portable plugin exports. One name for every plugin: a
81/// portable plugin is opened, never linked in, so two of them never share a
82/// symbol table.
83pub const PLUGIN_ENTRY_SYMBOL: &str = "lumen_plugin_v1";
84
85/// The prefix of the C-ABI probe an engine-locked module exports; the module's
86/// declared name completes it. The probe returns the module's NUL-terminated
87/// `BUILD_ID`, read before any Rust symbol is touched.
88pub const PROBE_PREFIX: &str = "lumen_module_probe_";
89/// The prefix of the Rust-ABI install entry, called only after the probe
90/// matched exactly.
91pub const INSTALL_PREFIX: &str = "lumen_module_install_";
92/// The prefix of the registration entry every module exports, opened or
93/// linked. Naming it on a link line is what pulls a module out of an archive
94/// that nothing else references.
95pub const REGISTER_PREFIX: &str = "lumen_module_register_";
96
97/// One module's entry symbol: the prefix, then the declared name with every
98/// character a symbol cannot carry replaced by `_`.
99///
100/// `lumen-module-macros` spells the same names at the module's compile time,
101/// from the name its author passed to `lumen_module!`. The two spellings are
102/// the contract between a module and the loader: a module declared under a
103/// name it was not built with reads as a library exporting nothing. They live
104/// here rather than beside the loader because a link kit spells them with no
105/// loader in the build at all.
106pub 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/// `version`-source resolutions handed to the loader by the compiler, keyed
117/// by module name.
118///
119/// The runtime never resolves a version itself - no semver, no registry, no
120/// lock in its graph. `lumenc` asks `lpm` to resolve each `version`
121/// requirement before the app builds, and hands the outcome in here: `Ok` is
122/// the library file to open, `Err` is the reason resolution failed, which the
123/// loader banners in place of its own probe. A module absent from the map
124/// falls back to the loader's on-disk probe (the `modules/` directories a
125/// build step stages into).
126#[derive(Debug, Clone, Default)]
127pub struct ResolvedModules(pub BTreeMap<String, Result<PathBuf, String>>);
128
129// The candidate file names of a library, shared with every other loader that
130// probes by name. The one copy lives beside the `[[plugins]]` path resolution
131// in `lumen-plugin-abi`; the loader's on-disk probe and `lumenc package` both
132// read this re-export.
133pub use lumen_plugin_abi::config::library_spellings;
134
135/// The markup tags the language itself owns, which no module may claim.
136///
137/// A module declaring `tags = ["button"]` would not add a tag, it would
138/// quietly reinterpret one every app already writes, and whether it won
139/// would depend on load order. Refusing the declaration is what keeps a
140/// built-in tag meaning one thing.
141///
142/// The list is spelled here rather than imported because the parser sits
143/// above this crate, not below it; `lumenc` owns a test that fails when the
144/// two drift.
145pub 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/// The parsed `[dependencies]` table: one [`DepCfg`] per declared module,
187/// sorted by name, which is the load order.
188#[derive(Debug, Clone, Default, PartialEq)]
189pub struct DependenciesCfg(pub Vec<DepCfg>);
190
191impl DependenciesCfg {
192    /// Every tag the table declares, in load order, deduplicated.
193    ///
194    /// Two modules may name the same tag; the parser only needs to know the
195    /// tag exists, and which module answers for it is settled later by which
196    /// one adopts the element.
197    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    /// This table with `overlay`'s entries laid over it: an entry `overlay`
213    /// declares replaces the one of the same name here, whole, and the rest
214    /// of both are kept. Sorted by name, which is the load order.
215    ///
216    /// This is how `[target.<name>.dependencies]` combines with
217    /// `[dependencies]`, the same way cargo combines its target tables.
218    #[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/// The kind of platform a build is for.
233///
234/// Every build is for exactly one, and it decides two things: which
235/// `[target.<name>.dependencies]` table is laid over `[dependencies]`, and
236/// which names a script's compile-time conditions are true for.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
238pub enum Target {
239    /// A site, run by the browser runtime.
240    Web,
241    /// A desktop app: `lumenc run`, `build`, `bundle` and `package`.
242    Desktop,
243}
244
245impl Target {
246    /// Every target, in the order the tables are documented.
247    pub const ALL: [Target; 2] = [Target::Web, Target::Desktop];
248
249    /// The name `lumen.toml` writes it as: `[target.web]`, `[target.desktop]`.
250    pub fn name(self) -> &'static str {
251        match self {
252            Target::Web => "web",
253            Target::Desktop => "desktop",
254        }
255    }
256
257    /// The names a script's compile-time conditions are true for, when a
258    /// program is compiled for this target.
259    ///
260    /// This is the one place the set is written. The compile that honours a
261    /// condition takes it from here, so a target that gains a name gains it
262    /// everywhere at once.
263    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/// The `[target]` table: what each target adds to the app's configuration.
278#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
279#[serde(default, deny_unknown_fields)]
280pub struct TargetTables {
281    /// `[target.web]`.
282    pub web: TargetCfg,
283    /// `[target.desktop]`.
284    pub desktop: TargetCfg,
285}
286
287impl TargetTables {
288    /// The table for `target`.
289    pub fn get(&self, target: Target) -> &TargetCfg {
290        match target {
291            Target::Web => &self.web,
292            Target::Desktop => &self.desktop,
293        }
294    }
295
296    /// The dependencies a build for `target` takes: `base` with that
297    /// target's own table laid over it.
298    pub fn dependencies_for(&self, base: &DependenciesCfg, target: Target) -> DependenciesCfg {
299        base.merged(&self.get(target).dependencies)
300    }
301}
302
303/// One `[target.<name>]` table.
304#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
305#[serde(default, deny_unknown_fields)]
306pub struct TargetCfg {
307    /// `[target.<name>.dependencies]`: entries of the same shape as
308    /// `[dependencies]`, used only by a build for this target.
309    pub dependencies: DependenciesCfg,
310}
311
312/// Publish the tags an app's `[dependencies]` table declares, so the markup
313/// parser accepts them.
314///
315/// A module that answers for a tag registers it from its `Plugin::build`,
316/// which is too late for a build that cannot install the module before it
317/// parses, such as one whose library is not built yet. The `tags` key is the
318/// declaration that works on every path, and this is what turns it into the
319/// parser's answer. Call it after the config is read and before any
320/// markup is parsed.
321///
322/// The registry is process-wide and holds what it is given for the life of
323/// the process, which is the same shape a `#[derive(Widget)]` tag has always
324/// had. Every tool that calls this handles one app per process, so nothing
325/// today sees another app's tags; a process that compiled two apps would let
326/// the second parse markup the first declared. That is a missed error rather
327/// than a wrong artifact - the tag still resolves to nothing at run time -
328/// but it is why scoping the registry per compile is worth doing before
329/// anything compiles two apps at once.
330pub fn register_declared_tags(deps: &DependenciesCfg) {
331    for tag in deps.declared_tags() {
332        lumen_widget::register_widget_tag_owned(tag);
333    }
334}
335
336/// Publish the elements the add-ons `addons` answer for, so the markup parser
337/// accepts them. The same registry [`register_declared_tags`] writes to: a
338/// compile does not install a module that has a web half, so the descriptor
339/// is what tells the parser the element exists.
340pub 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
346/// Check one declared tag name, or say why it is refused.
347fn 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/// One `[dependencies]` entry.
371#[derive(Debug, Clone, PartialEq)]
372pub struct DepCfg {
373    /// Module name - the table key.
374    pub name: String,
375    /// Where the module's library comes from.
376    pub source: ModuleSource,
377    /// The module's own configuration, handed to it verbatim at install.
378    pub config: toml::Table,
379    /// Markup tags this module answers for, so the parser accepts them
380    /// before anything is loaded.
381    ///
382    /// A compile that cannot install the module before it parses has only
383    /// this to go on, so a tag such a module introduces has to be declared
384    /// for the parse to accept it. Declaring a tag no module answers for
385    /// costs an element that renders as an empty box.
386    pub tags: Vec<String>,
387}
388
389/// Where one module's library comes from. A bare string value is shorthand
390/// for [`ModuleSource::Version`].
391#[derive(Debug, Clone, PartialEq)]
392pub enum ModuleSource {
393    /// Ships with the toolchain, beside the running engine.
394    Bundled,
395    /// A version requirement on a registry package, resolved by `lpm` before
396    /// the app builds; the runtime never resolves or fetches one, it only
397    /// loads a module already on disk.
398    Version(String),
399    /// A built library on disk, relative to the app directory unless
400    /// absolute. Without an extension, the platform spellings are probed
401    /// (`lib<m>.so`, `lib<m>.dylib`, `<m>.dll`, plus underscored variants
402    /// for a hyphenated name).
403    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        // A BTreeMap so iteration - and therefore load order - is the sorted
412        // key order, the only stable order an unordered TOML table has.
413        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    /// Parse one `name = <value>` entry of the `[dependencies]` table.
424    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}