Skip to main content

lumen_runtime/run/
check.rs

1use super::*;
2use crate::app_layout::AppLayout;
3use lumen_modules::language::{Form, LanguageTable, ScriptGrouping};
4use lumen_script::{ScriptCompile, ScriptLanguages};
5
6/// Summary returned by [`check_app`].
7#[derive(Debug, Clone, Copy)]
8pub struct CheckReport {
9    /// Number of elements parsed (including root).
10    pub element_count: usize,
11    /// Whether the markup contained a non-empty `<script>` block.
12    pub has_script: bool,
13}
14
15/// What a compile takes from outside the app directory: the target it builds
16/// for, and what `lumenc` resolved for that target before it started.
17///
18/// The runtime resolves nothing itself, so a caller with no registry and no
19/// add-on in reach passes [`CompileDeps::new`] and the compile reads the app
20/// alone.
21#[derive(Debug, Clone)]
22pub struct CompileDeps {
23    /// The target the program is compiled for: it decides which
24    /// `[target.<name>.dependencies]` table applies, and the names a script's
25    /// compile-time conditions are true for
26    /// ([`lumen_modules::Target::cfg_flags`]).
27    pub target: lumen_modules::Target,
28    /// Script libraries the app imports, as the name a script imports under
29    /// and the directory holding its sources.
30    pub import_roots: Vec<(String, PathBuf)>,
31    /// What the web halves of the modules the app depends on declare. A
32    /// module's descriptor is its script surface on every target, so their
33    /// functions are declared to the compile and their elements to the
34    /// parser whatever the target, and the compiled app carries them. The
35    /// functions are declared here and bound, at run time, to whatever the
36    /// loaded module registers.
37    pub addons: Vec<lumen_ir::addon::Addon>,
38    /// What the modules with no web half register for scripts, read by
39    /// installing each the way a run does ([`module_surface`]). Empty for a
40    /// web build, which cannot take such a module.
41    pub modules: ModuleSurface,
42}
43
44impl CompileDeps {
45    /// A compile for `target` with nothing resolved from outside the app.
46    pub fn new(target: lumen_modules::Target) -> Self {
47        Self {
48            target,
49            import_roots: Vec::new(),
50            addons: Vec::new(),
51            modules: ModuleSurface::default(),
52        }
53    }
54}
55
56/// What a set of modules registers for scripts: the functions a program
57/// calls them by, and the language sources compiled ahead of it.
58///
59/// Each function keeps its name, namespace and signature, and its body is
60/// replaced by one that answers the zero of its declared return type (`0`,
61/// `false`, the empty string, an empty list or map, null). A compile declares
62/// the functions and a check runs the script's `main` once, so a call there
63/// answers without doing the module's work.
64#[derive(Debug, Clone, Default)]
65pub struct ModuleSurface {
66    /// The functions, in registration order.
67    pub fns: Vec<lumen_script::ScriptFn>,
68    /// The language sources, in registration order.
69    pub preludes: Vec<lumen_script::ScriptPrelude>,
70}
71
72/// What the modules in `deps` register for scripts, read the way a run reads
73/// it: each is installed into a scratch app, in headless mode, by the loader a
74/// run uses, and its registrations are taken back out. A module compiled into
75/// this binary answers from there, and any other is opened from disk, so the
76/// surface is the one the app's run binds to.
77///
78/// `dir` is the app directory and `resolved` what the compiler resolved for
79/// the `version` sources. A module that does not load prints the loader's
80/// banner and registers nothing, so a call into it fails the compile that
81/// follows.
82#[cfg(feature = "modules")]
83pub fn module_surface(
84    dir: &Path,
85    deps: &lumen_modules::DependenciesCfg,
86    resolved: &lumen_modules::ResolvedModules,
87) -> ModuleSurface {
88    if deps.0.is_empty() {
89        return ModuleSurface::default();
90    }
91    let app_id = crate::config::LumenToml::load_or_default(dir)
92        .ok()
93        .and_then(|cfg| cfg.app.id)
94        .unwrap_or_else(|| lumen_capability::derive_app_id(dir));
95    let mut app = App::new();
96    app.world
97        .insert_resource(lumen_core::app::RunMode { headless: true });
98    let env = crate::modules::InitEnv {
99        app_dir: dir.to_path_buf(),
100        app_id,
101        headless: true,
102        hot_reload: false,
103    };
104    crate::modules::load_modules(&mut app, dir, deps, resolved, &env);
105    let Some(registry) = app.world.get_resource::<lumen_script::ScriptFnRegistry>() else {
106        return ModuleSurface::default();
107    };
108    ModuleSurface {
109        fns: registry.fns().iter().map(answering_zero).collect(),
110        preludes: registry.preludes().to_vec(),
111    }
112}
113
114/// `f` with a body that answers the zero of its declared return type.
115#[cfg(feature = "modules")]
116fn answering_zero(f: &lumen_script::ScriptFn) -> lumen_script::ScriptFn {
117    let zero = zero_of(&f.sig.ret);
118    let mut declared = f.clone();
119    declared.body = std::sync::Arc::new(move |_| Ok(zero.clone()));
120    declared
121}
122
123/// The zero of a declared type.
124#[cfg(feature = "modules")]
125fn zero_of(ty: &lumen_script::ScriptTy) -> lumen_script::ScriptValue {
126    use lumen_script::{ScriptTy, ScriptValue};
127    match ty {
128        ScriptTy::Bool => ScriptValue::Bool(false),
129        ScriptTy::Int => ScriptValue::I64(0),
130        ScriptTy::Float => ScriptValue::F64(0.0),
131        ScriptTy::Str => ScriptValue::Str(String::new()),
132        ScriptTy::Array(_) => ScriptValue::Array(Vec::new()),
133        ScriptTy::Map(_) => ScriptValue::Map(Default::default()),
134        ScriptTy::Struct(shape) => shape.default_value(),
135        ScriptTy::Any | ScriptTy::Unit | ScriptTy::Dynamic => ScriptValue::Unit,
136    }
137}
138
139/// The functions a compile declares: the add-ons' functions, bound to the
140/// body every target without a page binds, and the modules' functions as
141/// [`ModuleSurface`] holds them. A compiler declares them; nothing a run
142/// registers comes from here, since a run binds the module's own.
143#[cfg(feature = "runtime-parse")]
144fn declared_fns(deps: &CompileDeps) -> Result<Vec<lumen_script::ScriptFn>, RunError> {
145    let mut fns = addon_stubs(&deps.addons)?;
146    fns.extend(deps.modules.fns.iter().cloned());
147    Ok(fns)
148}
149
150/// The add-ons' functions alone, bound to the body every target without a
151/// page binds.
152#[cfg(feature = "runtime-parse")]
153fn addon_stubs(addons: &[lumen_ir::addon::Addon]) -> Result<Vec<lumen_script::ScriptFn>, RunError> {
154    let mut fns = Vec::new();
155    for addon in addons {
156        fns.extend(lumen_script::addon::browser_only_fns(addon, None).map_err(RunError::Script)?);
157    }
158    Ok(fns)
159}
160
161/// Ahead-of-time compile an app directory into a
162/// [`lumen_ir::artifact::CompiledApp`]: parse markup + CSS once, run the full
163/// cascade, resolve asset / include / import paths, and bake the combined
164/// script source. The returned artifact is what `lumenc build` writes to
165/// disk and what a parser-free runtime loads via [`RunOptions::artifact`].
166///
167/// This is the AOT counterpart to [`load_ir`]: same front-end work, on the
168/// same discovered page set, but the result is serialized instead of spawned.
169/// A multi-page app compiles whole - every page assembled into the one gated
170/// tree the run path builds, plus the page set the routing needs. Requires the
171/// source parser (`runtime-parse` feature).
172///
173/// What a script compiler warned about is added to `warnings`, one line each,
174/// for the caller to print where it prints its own warnings.
175#[cfg(feature = "runtime-parse")]
176pub fn compile_app(
177    dir: &Path,
178    parser: &dyn SourceParser,
179    plugins: &dyn crate::compiler_plugins::CompilerPlugins,
180    deps: &CompileDeps,
181    warnings: &mut Vec<String>,
182) -> Result<lumen_ir::artifact::CompiledApp, RunError> {
183    compile_app_with_skin(dir, parser, plugins, None, deps, warnings)
184}
185
186/// [`compile_app`] with the skin named outright instead of read from
187/// `lumen.toml`.
188///
189/// One caller needs that: a site is built once and served to every OS, so it
190/// cannot let `[skin] name = "auto"` resolve against whichever machine ran
191/// the build. Everything else compiles an app for the machine it will run
192/// on and calls [`compile_app`].
193#[cfg(feature = "runtime-parse")]
194pub fn compile_app_with_skin(
195    dir: &Path,
196    parser: &dyn SourceParser,
197    plugins: &dyn crate::compiler_plugins::CompilerPlugins,
198    skin: Option<&str>,
199    deps: &CompileDeps,
200    warnings: &mut Vec<String>,
201) -> Result<lumen_ir::artifact::CompiledApp, RunError> {
202    let cfg = crate::config::LumenToml::load_or_default(dir).map_err(RunError::Config)?;
203    register_declared_tags(&cfg, &[deps.target], &deps.addons);
204    let stubs = declared_fns(deps)?;
205    let layout = AppLayout::resolve(dir, &cfg)?;
206    // The same discovery the run path does, so compiling sees the app the way
207    // running it does: the entry file it would open, and every sibling page.
208    let plan = crate::pages::discover(&layout.src_dir, &cfg);
209    let html_path = plan.entry_file.clone();
210    let css_path = layout.css_path;
211    let asset_roots = cfg.resolved_asset_roots(dir);
212    let skin_override = skin.map(str::to_string).or_else(|| cfg.skin.name.clone());
213    let table = language_table()?;
214    let grouping = ScriptGrouping {
215        table: &table,
216        engine: cfg.script.engine(),
217    };
218    // The hosts that read the scripts' markup blocks and compile them: the
219    // source-form provider of each language the markup names.
220    let registered = language_hosts(
221        dir,
222        &table,
223        &source_languages(parser, grouping, &html_path, None, Some(&plan)),
224    );
225    let loaded = load_ir(
226        parser,
227        plugins,
228        &html_path,
229        &css_path,
230        dir,
231        &asset_roots,
232        skin_override.as_deref(),
233        &lumen_ir::css::MediaContext::default(),
234        SourceOverrides {
235            plan: Some(&plan),
236            ..SourceOverrides::default()
237        },
238        &Languages {
239            grouping,
240            registered: &registered,
241        },
242    )?;
243    // Which language runs which part of the program, and which module runs
244    // it in a shipped app, is decided here, at compile time, from the script
245    // files' own extensions and the language descriptors. The runtime cannot
246    // rediscover it later: a shipped app carries no script files to read.
247    //
248    // A language with a bytecode form compiles here, and its source is
249    // dropped: a shipped app runs the image on a host with no compiler. The
250    // others ship their source, run by the one module they have.
251    let uri = html_path.display().to_string();
252    let mut scripts = Vec::new();
253    for script in grouped_script_sources(&loaded.ir, dir, grouping)? {
254        let (language, source, source_map) = (script.language, script.source, script.map);
255        let Some(shipped) = table.shipped_provider(&language) else {
256            warnings.push(format!(
257                "no script host module provides the `{language}` language, so its script \
258                 will not run"
259            ));
260            scripts.push(lumen_ir::artifact::CompiledScript {
261                engine: language,
262                module: String::new(),
263                source,
264                bytecode: None,
265                source_map,
266            });
267            continue;
268        };
269        let module = shipped.module.clone();
270        if shipped.form != Form::Bytecode {
271            scripts.push(lumen_ir::artifact::CompiledScript {
272                engine: language,
273                module,
274                source,
275                bytecode: None,
276                source_map,
277            });
278            continue;
279        }
280        let compile = registered
281            .get(&language)
282            .and_then(|l| l.compile)
283            .ok_or_else(|| {
284                RunError::Script(format!(
285                    "no compiler for the `{language}` language is loaded; its host module \
286                     ({}) did not load",
287                    table
288                        .source_provider(&language)
289                        .map_or("none in reach", |p| p.module.as_str())
290                ))
291            })?;
292        let flags = deps.target.cfg_flags();
293        let against = ScriptCompile {
294            uri: &uri,
295            source_map: &source_map,
296            lib_dir: Some(&layout.lib_dir),
297            import_roots: &deps.import_roots,
298            cfg_flags: flags,
299            fns: &stubs,
300            preludes: &deps.modules.preludes,
301        };
302        let (image, raised) = compile(&source, &against).map_err(RunError::Script)?;
303        warnings.extend(raised);
304        scripts.push(lumen_ir::artifact::CompiledScript {
305            engine: language,
306            module,
307            source: String::new(),
308            bytecode: Some(image),
309            source_map: Default::default(),
310        });
311    }
312    // Routing data for a multi-page app. The pages themselves are already in
313    // the tree, each behind its gate; this is the part the runtime would
314    // otherwise rediscover by listing `.lmn` files.
315    let pages = plan.multipage.then(|| lumen_ir::artifact::CompiledPages {
316        entry: plan.entry_key.clone(),
317        keys: plan.keys(),
318    });
319    let mut ir = loaded.ir;
320    ir.script_source = String::new();
321    ir.script_map = lumen_ir::source_map::SourceMap::default();
322    ir.external_scripts.clear();
323    // Every catalogue travels in the artifact, so an app compiled here reads
324    // in its languages with no `locale/` directory beside it. A loose file
325    // still wins where there is one. Each is parsed here, so a broken one
326    // fails the build rather than the app.
327    let fallback: Vec<String> = cfg
328        .app
329        .fallback_locale
330        .iter()
331        .map(ToString::to_string)
332        .collect();
333    let catalogues = lumen_i18n::read_catalogues(&super::locale_dir(dir), |p| std::fs::read(p))
334        .and_then(|catalogues| {
335            lumen_i18n::Catalogues::parse(&catalogues, &fallback).map(|_| catalogues)
336        })
337        .map_err(|e| RunError::I18n(e.to_string()))?;
338    let i18n = lumen_ir::artifact::CompiledI18n {
339        catalogues,
340        fallback,
341    };
342    Ok(lumen_ir::artifact::CompiledApp {
343        ir,
344        scripts,
345        pages,
346        // Every fragment the app declares, whether or not this build
347        // instantiates it: the artifact carries the declarations, not just
348        // their expansions.
349        fragments: loaded.fragments,
350        i18n,
351        addons: deps.addons.clone(),
352        // Filled by `lumenc package`, which is what decides the engine an app
353        // ships with; a compile alone links none.
354        unlinked: Vec::new(),
355    })
356}
357
358/// The language descriptors in reach, or the reason they disagree.
359#[cfg(feature = "runtime-parse")]
360fn language_table() -> Result<LanguageTable, RunError> {
361    LanguageTable::discover(None).map_err(RunError::Script)
362}
363
364/// The languages registered by the source-form host modules of `languages`,
365/// installed into a scratch app the way a run installs them. A module that
366/// does not load prints the loader's banner and registers nothing.
367#[cfg(all(feature = "runtime-parse", feature = "modules"))]
368fn language_hosts(dir: &Path, table: &LanguageTable, languages: &[String]) -> ScriptLanguages {
369    let modules: Vec<String> = languages
370        .iter()
371        .filter_map(|language| table.source_provider(language))
372        .map(|provider| provider.module.clone())
373        .collect();
374    hosts_of(dir, &modules)
375}
376
377#[cfg(all(feature = "runtime-parse", not(feature = "modules")))]
378fn language_hosts(_dir: &Path, _table: &LanguageTable, _languages: &[String]) -> ScriptLanguages {
379    ScriptLanguages::default()
380}
381
382/// The languages the host modules `modules` register, read by installing
383/// each into a scratch app in headless mode.
384#[cfg(feature = "modules")]
385fn hosts_of(dir: &Path, modules: &[String]) -> ScriptLanguages {
386    if modules.is_empty() {
387        return ScriptLanguages::default();
388    }
389    let mut app = App::new();
390    app.world
391        .insert_resource(lumen_core::app::RunMode { headless: true });
392    let env = crate::modules::InitEnv {
393        app_dir: dir.to_path_buf(),
394        app_id: lumen_capability::derive_app_id(dir),
395        headless: true,
396        hot_reload: false,
397    };
398    let deps =
399        lumen_modules::language::with_implied(&lumen_modules::DependenciesCfg::default(), modules);
400    crate::modules::load_modules(
401        &mut app,
402        dir,
403        &deps,
404        &lumen_modules::ResolvedModules::default(),
405        &env,
406    );
407    app.world
408        .get_resource::<ScriptLanguages>()
409        .cloned()
410        .unwrap_or_default()
411}
412
413/// The names a compiled program can be called by.
414///
415/// `None` for a program with no ahead-of-time form, and for one whose
416/// language has no host in reach that can read an image back: neither knows
417/// what the program exports, and neither can say anything is missing from it.
418///
419/// A build tool asks this to tell a function the app calls by name from one
420/// it will only appear to call: candela exports a function only when every
421/// parameter it takes is annotated, and a shipped runtime carries no compiler
422/// to fall back on.
423///
424/// `dir` is the app the program belongs to. `addons` are the browser add-ons
425/// the app was compiled against; their functions are declared in the program,
426/// so reading it back binds them too.
427#[cfg(all(feature = "runtime-parse", feature = "modules"))]
428#[must_use]
429pub fn script_exports(
430    dir: &Path,
431    script: &lumen_ir::artifact::CompiledScript,
432    addons: &[lumen_ir::addon::Addon],
433) -> Option<Result<Vec<String>, String>> {
434    let bytecode = script.bytecode.as_deref()?;
435    if script.module.is_empty() {
436        return None;
437    }
438    let hosts = hosts_of(dir, std::slice::from_ref(&script.module));
439    let read = hosts.get(&script.engine)?.image_exports?;
440    let fns = match addon_stubs(addons) {
441        Ok(fns) => fns,
442        Err(e) => return Some(Err(e.to_string())),
443    };
444    Some(read(bytecode, &fns))
445}
446
447/// Parse `<dir>/src/main.lmn` + optional `<dir>/src/main.css` and validate
448/// them without spawning a window. Used by CI / pre-commit hooks.
449///
450/// Parse-time `LayoutIR.lint_findings` (an unknown attribute, a
451/// boolean attribute with an off-list value, bare `{name}`
452/// interpolation) are printed to stderr but never fail the build -
453/// `check` validates AST shape, not style. Run
454/// `lumenc lint --signals <dir>` for the full lint stream.
455///
456/// `check` is for no one target. `targets` holds what was resolved for each
457/// target the app can be built for: the markup is checked once, accepting the
458/// elements of every target's add-ons, and the scripts are compiled once per
459/// target, each against that target's add-ons, script libraries and
460/// `@cfg(...)` flags, so code written for one target is checked the way that
461/// target's build compiles it.
462///
463/// Requires the source parser (`runtime-parse` feature).
464#[cfg(feature = "runtime-parse")]
465pub fn check_app(
466    dir: &Path,
467    parser: &dyn SourceParser,
468    plugins: &dyn crate::compiler_plugins::CompilerPlugins,
469    targets: &[CompileDeps],
470) -> Result<CheckReport, RunError> {
471    let cfg = crate::config::LumenToml::load_or_default(dir).map_err(RunError::Config)?;
472    let mut every_addon: Vec<lumen_ir::addon::Addon> = Vec::new();
473    for addon in targets.iter().flat_map(|deps| &deps.addons) {
474        if !every_addon.iter().any(|a| a.name == addon.name) {
475            every_addon.push(addon.clone());
476        }
477    }
478    let every_target: Vec<lumen_modules::Target> = targets.iter().map(|deps| deps.target).collect();
479    register_declared_tags(&cfg, &every_target, &every_addon);
480    let layout = AppLayout::resolve(dir, &cfg)?;
481    let roots = cfg.resolved_asset_roots(dir);
482    // File-based pages: validate the whole assembled multi-page tree (entry +
483    // grafted sibling pages + global templates), not just the entry file in
484    // isolation - otherwise an entry that `<use>`s a shared `layout` template
485    // would falsely fail `check`.
486    let plan = crate::pages::discover(&layout.src_dir, &cfg);
487    let entry_path = plan.entry_file.clone();
488    let table = language_table()?;
489    let grouping = ScriptGrouping {
490        table: &table,
491        engine: cfg.script.engine(),
492    };
493    let registered = language_hosts(
494        dir,
495        &table,
496        &source_languages(parser, grouping, &entry_path, None, Some(&plan)),
497    );
498    let LoadResult { ir, .. } = load_ir(
499        parser,
500        plugins,
501        &entry_path,
502        &layout.css_path,
503        dir,
504        &roots,
505        cfg.skin.name.as_deref(),
506        &lumen_ir::css::MediaContext::default(),
507        SourceOverrides {
508            plan: Some(&plan),
509            ..SourceOverrides::default()
510        },
511        &Languages {
512            grouping,
513            registered: &registered,
514        },
515    )?;
516    // Parse-time lint findings already went to stderr from `load_ir`,
517    // which every compile path shares.
518    let has_script = !ir.script_source.trim().is_empty() || !ir.external_scripts.is_empty();
519    // RC6: compile the app's scripts with the same engine settings
520    // `lumenc run` uses. Compile-only - the top level is never evaluated, so
521    // `check` stays side-effect free. A script that would die at load (parse
522    // error, expression-depth overflow, ...) fails the check instead of
523    // false-passing while `run` shows a window whose every handler is dead.
524    //
525    // Check each language's program with its own host's checker, on the
526    // same grouping `build_app` runs: one language's checker false-fails on
527    // another's syntax, so a mixed app checked as one blob could never pass.
528    let uri = entry_path.display().to_string();
529    for script in grouped_script_sources(&ir, dir, grouping)? {
530        let (language, source) = (script.language, script.source);
531        let Some(check) = registered.get(&language).and_then(|l| l.check) else {
532            return Err(RunError::Script(format!(
533                "{uri}: no script host for the `{language}` language is loaded to check its \
534                 script with; install {}",
535                table.source_provider(&language).map_or(
536                    "a module that provides it".to_string(),
537                    |p| format!("the `{}` module", p.module)
538                )
539            )));
540        };
541        check_per_target(check, &source, &uri, &script.map, &layout.lib_dir, targets)?;
542    }
543    Ok(CheckReport {
544        element_count: count_elements(&ir.root),
545        has_script,
546    })
547}
548
549/// Compile-check a program once for each of `targets`, each against that
550/// target's functions, script libraries, and compile-time conditions.
551///
552/// Where every target rejects the program the same way, the error is the
553/// checker's own, as it is for an app with no target-specific code. Where
554/// they disagree, the error names the target whose build it breaks.
555#[cfg(feature = "runtime-parse")]
556fn check_per_target(
557    check: lumen_script::language::CheckFn,
558    source: &str,
559    uri: &str,
560    source_map: &lumen_ir::source_map::SourceMap,
561    lib_dir: &Path,
562    targets: &[CompileDeps],
563) -> Result<(), RunError> {
564    let mut failures = Vec::new();
565    for deps in targets {
566        let stubs = declared_fns(deps)?;
567        let against = ScriptCompile {
568            uri,
569            source_map,
570            lib_dir: Some(lib_dir),
571            import_roots: &deps.import_roots,
572            cfg_flags: deps.target.cfg_flags(),
573            fns: &stubs,
574            preludes: &deps.modules.preludes,
575        };
576        if let Err(e) = check(source, &against) {
577            failures.push((deps.target, e));
578        }
579    }
580    let Some((target, first)) = failures.first() else {
581        return Ok(());
582    };
583    let agree = failures.len() == targets.len() && failures.iter().all(|(_, e)| e == first);
584    Err(RunError::Script(if agree {
585        first.clone()
586    } else {
587        format!("{first} (in the {target} build)")
588    }))
589}
590
591#[cfg(feature = "runtime-parse")]
592fn count_elements(el: &Element) -> usize {
593    1 + el.children.iter().map(count_elements).sum::<usize>()
594}
595
596/// Walk the IR and rewrite every `src` attribute on tags that load
597/// assets (`<image>`) to be absolute, joining the path against the
598/// app directory. Author-written relative paths then survive
599/// arbitrary cwd shifts at run time.
600///
601/// Runs on the from-source load and on the artifact load alike, which is what
602/// lets a packaged app carry paths relative to itself and still find its
603/// files from whichever directory it was started in.
604///
605/// `dir` may be relative to the working directory, as it is when a user
606/// types `lumenc build tracker`; it is made absolute first. A path joined
607/// onto a relative `dir` would stay relative, and the artifact load would
608/// then join `dir` onto it a second time.
609pub(crate) fn resolve_asset_paths(el: &mut Element, dir: &Path, extra_roots: &[PathBuf]) {
610    let dir = absolute_dir(dir);
611    let extra_roots: Vec<PathBuf> = extra_roots.iter().map(|r| absolute_dir(r)).collect();
612    resolve_asset_paths_under(el, &dir, &extra_roots);
613}
614
615/// `dir` made absolute against the working directory. A path the platform
616/// cannot make absolute (an empty one, or a working directory that is gone)
617/// is returned as given.
618pub(crate) fn absolute_dir(dir: &Path) -> PathBuf {
619    std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf())
620}
621
622fn resolve_asset_paths_under(el: &mut Element, dir: &Path, extra_roots: &[PathBuf]) {
623    if el.tag == "image"
624        && let Some(src) = &el.attrs.src
625    {
626        let p = Path::new(src);
627        if p.is_relative() {
628            // Prefer the app dir first; fall back to extra `asset_roots`
629            // from lumen.toml in declared order. We only swap to an extra
630            // root if a file actually exists there - keeps the default
631            // path stable when no overrides are configured.
632            let primary = dir.join(p);
633            let resolved = if primary.exists() {
634                primary
635            } else {
636                extra_roots
637                    .iter()
638                    .map(|r| r.join(p))
639                    .find(|cand| cand.exists())
640                    .unwrap_or(primary)
641            };
642            if let Some(s) = resolved.to_str() {
643                el.attrs.src = Some(s.to_string());
644            }
645        }
646    }
647    for child in &mut el.children {
648        resolve_asset_paths_under(child, dir, extra_roots);
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use lumen_ir::layout_ir::Attributes;
656
657    fn image(src: &str) -> Element {
658        Element {
659            tag: "image".to_string(),
660            attrs: Attributes {
661                src: Some(src.to_string()),
662                ..Default::default()
663            },
664            ..Default::default()
665        }
666    }
667
668    /// A relative app directory still yields an absolute `src`, rooted at the
669    /// working directory.
670    #[test]
671    fn a_relative_app_dir_resolves_to_an_absolute_src() {
672        let mut el = image("icons/gear.png");
673        resolve_asset_paths(&mut el, Path::new("tracker"), &[]);
674        let expected = std::env::current_dir()
675            .unwrap()
676            .join("tracker")
677            .join("icons/gear.png");
678        assert_eq!(el.attrs.src.as_deref(), expected.to_str());
679    }
680
681    /// `lumenc build tracker` then `lumenc run --artifact ... tracker`: the
682    /// compile resolves the path against `tracker`, and the artifact load
683    /// resolves it against `tracker` again. The second pass must leave it
684    /// alone rather than produce `tracker/tracker/icons/gear.png`.
685    #[test]
686    fn resolving_twice_against_a_relative_dir_does_not_double_it() {
687        let mut el = image("icons/gear.png");
688        resolve_asset_paths(&mut el, Path::new("tracker"), &[]);
689        let once = el.attrs.src.clone();
690        resolve_asset_paths(&mut el, Path::new("tracker"), &[]);
691        assert_eq!(el.attrs.src, once);
692        assert!(Path::new(el.attrs.src.as_deref().unwrap()).is_absolute());
693    }
694}