1use super::*;
2use crate::app_layout::AppLayout;
3use lumen_modules::language::{Form, LanguageTable, ScriptGrouping};
4use lumen_script::{ScriptCompile, ScriptLanguages};
5
6#[derive(Debug, Clone, Copy)]
8pub struct CheckReport {
9 pub element_count: usize,
11 pub has_script: bool,
13}
14
15#[derive(Debug, Clone)]
22pub struct CompileDeps {
23 pub target: lumen_modules::Target,
28 pub import_roots: Vec<(String, PathBuf)>,
31 pub addons: Vec<lumen_ir::addon::Addon>,
38 pub modules: ModuleSurface,
42}
43
44impl CompileDeps {
45 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#[derive(Debug, Clone, Default)]
65pub struct ModuleSurface {
66 pub fns: Vec<lumen_script::ScriptFn>,
68 pub preludes: Vec<lumen_script::ScriptPrelude>,
70}
71
72#[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#[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#[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#[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#[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#[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#[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 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 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: ®istered,
241 },
242 )?;
243 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 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 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 fragments: loaded.fragments,
350 i18n,
351 addons: deps.addons.clone(),
352 unlinked: Vec::new(),
355 })
356}
357
358#[cfg(feature = "runtime-parse")]
360fn language_table() -> Result<LanguageTable, RunError> {
361 LanguageTable::discover(None).map_err(RunError::Script)
362}
363
364#[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#[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#[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#[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 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: ®istered,
514 },
515 )?;
516 let has_script = !ir.script_source.trim().is_empty() || !ir.external_scripts.is_empty();
519 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#[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
596pub(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
615pub(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 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 #[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 #[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}