1use std::path::{Component, Path, PathBuf};
43
44use lumen_ir::addon::{Addon, AddonElement, AddonFunction, AddonParam};
45use serde::Deserialize;
46
47pub const WEB_DIR: &str = "web";
49
50pub const ADDON_MANIFEST: &str = "lumen-addon.toml";
52
53pub const RESERVED_EXPORTS: &[&str] = &["install", "elements", "default"];
56
57pub const RESERVED_NAMESPACES: &[&str] = &["lumen", "native", "fs"];
60
61#[derive(Debug, Clone, PartialEq)]
64pub struct AddonPackage {
65 pub addon: Addon,
67 pub dir: PathBuf,
69 pub module: String,
71 pub styles: Vec<String>,
73 pub head: Option<String>,
76 pub files: Vec<String>,
79 pub config: toml::Table,
83}
84
85pub fn web_half(root: &Path) -> Option<PathBuf> {
88 let dir = root.join(WEB_DIR);
89 dir.join(ADDON_MANIFEST).is_file().then_some(dir)
90}
91
92pub fn read_web_half(name: &str, root: &Path) -> Result<AddonPackage, String> {
101 let dir = root.join(WEB_DIR);
102 let path = dir.join(ADDON_MANIFEST);
103 let text = std::fs::read_to_string(&path)
104 .map_err(|e| format!("the web half of '{name}': read {}: {e}", path.display()))?;
105 parse_addon(name, &dir, &text)
106}
107
108pub fn describe(name: &str, text: &str) -> Result<Addon, String> {
119 parse_descriptor(name, text).map(|(_, addon)| addon)
120}
121
122#[derive(Deserialize)]
124#[serde(deny_unknown_fields)]
125struct Descriptor {
126 addon: Header,
127 #[serde(default)]
128 function: Vec<FunctionDecl>,
129 #[serde(default)]
130 element: Vec<ElementDecl>,
131}
132
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135struct Header {
136 namespace: String,
137 module: String,
138 #[serde(default)]
139 styles: Vec<String>,
140 #[serde(default)]
141 head: Option<String>,
142 #[serde(default)]
143 files: Vec<String>,
144}
145
146#[derive(Deserialize)]
147#[serde(deny_unknown_fields)]
148struct FunctionDecl {
149 name: String,
150 #[serde(default)]
151 params: Vec<String>,
152 #[serde(default)]
153 returns: Option<String>,
154 #[serde(default, rename = "async")]
155 event: Option<String>,
156 #[serde(default)]
157 doc: String,
158}
159
160#[derive(Deserialize)]
161#[serde(deny_unknown_fields)]
162struct ElementDecl {
163 tag: String,
164 html: String,
165 #[serde(default)]
166 void: bool,
167}
168
169fn parse_addon(name: &str, dir: &Path, text: &str) -> Result<AddonPackage, String> {
171 let fail = |message: String| format!("the web half of '{name}': {message}");
172 let (header, addon) = parse_descriptor(name, text)?;
173 let file = |path: &str, what: &str| -> Result<String, String> {
174 package_file(dir, path).map_err(|why| fail(format!("{what} `{path}` {why}")))
175 };
176 let module = file(&header.module, "module")?;
177 if !(module.ends_with(".js") || module.ends_with(".mjs")) {
178 return Err(fail(format!(
179 "module `{module}` must be a JavaScript module (.js or .mjs)"
180 )));
181 }
182 let styles = header
183 .styles
184 .iter()
185 .map(|s| file(s, "stylesheet"))
186 .collect::<Result<Vec<_>, _>>()?;
187 let head = header
188 .head
189 .as_deref()
190 .map(|h| file(h, "head script"))
191 .transpose()?;
192 let files = header
193 .files
194 .iter()
195 .map(|f| file(f, "file"))
196 .collect::<Result<Vec<_>, _>>()?;
197
198 Ok(AddonPackage {
199 addon,
200 dir: dir.to_path_buf(),
201 module,
202 styles,
203 head,
204 files,
205 config: toml::Table::new(),
206 })
207}
208
209fn parse_descriptor(name: &str, text: &str) -> Result<(Header, Addon), String> {
211 let fail = |message: String| format!("the web half of '{name}': {message}");
212 let descriptor: Descriptor =
213 toml::from_str(text).map_err(|e| fail(format!("{ADDON_MANIFEST}: {e}")))?;
214 let header = descriptor.addon;
215
216 if !is_identifier(&header.namespace) {
217 return Err(fail(format!(
218 "namespace `{}` must be lowercase letters, digits and underscores, starting with a \
219 letter",
220 header.namespace
221 )));
222 }
223 if RESERVED_NAMESPACES.contains(&header.namespace.as_str()) {
224 return Err(fail(format!(
225 "namespace `{}` is taken by the runtime; pick another",
226 header.namespace
227 )));
228 }
229
230 let mut functions: Vec<AddonFunction> = Vec::with_capacity(descriptor.function.len());
231 for decl in descriptor.function {
232 let qualified = format!("{}::{}", header.namespace, decl.name);
233 if !is_identifier(&decl.name) {
234 return Err(fail(format!(
235 "function `{}` must be lowercase letters, digits and underscores, starting with \
236 a letter",
237 decl.name
238 )));
239 }
240 if RESERVED_EXPORTS.contains(&decl.name.as_str()) {
241 return Err(fail(format!(
242 "function `{}` shares its name with a module hook; rename it",
243 decl.name
244 )));
245 }
246 if functions.iter().any(|f| f.name == decl.name) {
247 return Err(fail(format!("function `{}` is declared twice", decl.name)));
248 }
249 let mut params = Vec::with_capacity(decl.params.len());
250 for spelling in &decl.params {
251 let Some((param, ty)) = spelling.split_once(':') else {
252 return Err(fail(format!(
253 "{qualified}: parameter `{spelling}` must be written `name: type`"
254 )));
255 };
256 let (param, ty) = (param.trim(), ty.trim());
257 if !is_identifier(param) || ty.is_empty() {
258 return Err(fail(format!(
259 "{qualified}: parameter `{spelling}` must be written `name: type`"
260 )));
261 }
262 params.push(AddonParam {
263 name: param.to_owned(),
264 ty: ty.to_owned(),
265 });
266 }
267 if let Some(event) = &decl.event {
268 if decl.returns.is_some() {
269 return Err(fail(format!(
270 "{qualified} is async, so its result arrives as `{event}` and it returns \
271 nothing; drop `returns`"
272 )));
273 }
274 if !is_identifier(event) {
275 return Err(fail(format!(
276 "{qualified}: event `{event}` must be lowercase letters, digits and \
277 underscores, starting with a letter"
278 )));
279 }
280 }
281 functions.push(AddonFunction {
282 name: decl.name,
283 params,
284 returns: decl.returns.unwrap_or_else(|| "null".to_owned()),
285 event: decl.event,
286 doc: decl.doc,
287 });
288 }
289
290 let mut elements: Vec<AddonElement> = Vec::with_capacity(descriptor.element.len());
291 for decl in descriptor.element {
292 crate::validate_tag(name, &decl.tag)?;
293 if elements.iter().any(|e| e.tag == decl.tag) {
294 return Err(fail(format!("element `{}` is declared twice", decl.tag)));
295 }
296 if !is_element_name(&decl.html) {
297 return Err(fail(format!(
298 "element `{}`: `html = \"{}\"` must be an HTML element name",
299 decl.tag, decl.html
300 )));
301 }
302 elements.push(AddonElement {
303 tag: decl.tag,
304 html: decl.html,
305 void: decl.void,
306 });
307 }
308
309 let addon = Addon {
310 name: name.to_owned(),
311 namespace: header.namespace.clone(),
312 functions,
313 elements,
314 };
315 Ok((header, addon))
316}
317
318fn package_file(dir: &Path, path: &str) -> Result<String, &'static str> {
321 let relative = Path::new(path);
322 if path.is_empty()
323 || relative.is_absolute()
324 || relative
325 .components()
326 .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
327 {
328 return Err("must be a path inside the package");
329 }
330 if !dir.join(relative).exists() {
331 return Err("is not in the package");
332 }
333 Ok(relative
334 .components()
335 .filter_map(|c| match c {
336 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
337 _ => None,
338 })
339 .collect::<Vec<_>>()
340 .join("/"))
341}
342
343fn is_identifier(name: &str) -> bool {
346 let mut chars = name.chars();
347 chars.next().is_some_and(|c| c.is_ascii_lowercase())
348 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
349}
350
351fn is_element_name(name: &str) -> bool {
354 let mut chars = name.chars();
355 chars.next().is_some_and(|c| c.is_ascii_lowercase())
356 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn package(test: &str, files: &[&str]) -> PathBuf {
365 let dir =
366 std::env::temp_dir().join(format!("lumen-modules-addon-{}-{test}", std::process::id()));
367 let _ = std::fs::remove_dir_all(&dir);
368 for file in files {
369 let path = dir.join(file);
370 std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
371 std::fs::write(&path, "").expect("write");
372 }
373 dir
374 }
375
376 const ECHO: &str = r#"
377[addon]
378namespace = "echo"
379module = "echo.js"
380styles = ["echo.css"]
381head = "early.js"
382files = ["assets"]
383
384[[function]]
385name = "shout"
386params = ["text: string"]
387returns = "string"
388doc = "Upper-case it."
389
390[[function]]
391name = "later"
392params = ["text: string", "ms : int"]
393async = "on_echo"
394
395[[element]]
396tag = "echo-view"
397html = "div"
398"#;
399
400 #[test]
401 fn a_descriptor_reads_into_the_addon_and_its_files() {
402 let dir = package(
403 "reads",
404 &["echo.js", "echo.css", "early.js", "assets/logo.svg"],
405 );
406 let web = dir.join(WEB_DIR);
407 std::fs::create_dir_all(&web).unwrap();
408 for file in ["echo.js", "echo.css", "early.js", "assets/logo.svg"] {
409 std::fs::create_dir_all(web.join(file).parent().unwrap()).unwrap();
410 std::fs::rename(dir.join(file), web.join(file)).unwrap();
411 }
412 std::fs::write(web.join(ADDON_MANIFEST), ECHO).unwrap();
413 assert_eq!(web_half(&dir), Some(web.clone()));
414 let package = read_web_half("echo", &dir).unwrap();
415 assert_eq!(package.dir, web);
416 assert_eq!(package.module, "echo.js");
417 assert_eq!(package.styles, ["echo.css"]);
418 assert_eq!(package.head.as_deref(), Some("early.js"));
419 assert_eq!(package.files, ["assets"]);
420 let addon = package.addon;
421 assert_eq!(addon.name, "echo");
422 assert_eq!(addon.namespace, "echo");
423 assert_eq!(addon.functions[0].returns, "string");
424 assert_eq!(addon.functions[0].event, None);
425 assert_eq!(addon.functions[1].returns, "null");
426 assert_eq!(addon.functions[1].event.as_deref(), Some("on_echo"));
427 assert_eq!(addon.functions[1].params[1].name, "ms");
428 assert_eq!(addon.functions[1].params[1].ty, "int");
429 assert_eq!(addon.elements[0].html, "div");
430 assert!(!addon.elements[0].void);
431 }
432
433 fn refusal(test: &str, text: &str) -> String {
434 let dir = package(
435 test,
436 &["echo.js", "echo.css", "early.js", "assets/logo.svg"],
437 );
438 parse_addon("echo", &dir, text).unwrap_err()
439 }
440
441 #[test]
442 fn a_file_outside_the_package_is_refused() {
443 let err = refusal(
444 "outside",
445 "[addon]\nnamespace = \"echo\"\nmodule = \"../echo.js\"\n",
446 );
447 assert!(err.contains("must be a path inside the package"), "{err}");
448 let err = refusal(
449 "missing",
450 "[addon]\nnamespace = \"echo\"\nmodule = \"gone.js\"\n",
451 );
452 assert!(err.contains("is not in the package"), "{err}");
453 }
454
455 #[test]
456 fn a_function_named_after_a_hook_is_refused() {
457 let err = refusal(
458 "hook",
459 "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \"install\"\n",
460 );
461 assert!(err.contains("module hook"), "{err}");
462 }
463
464 #[test]
465 fn the_runtimes_namespaces_are_refused() {
466 for ns in RESERVED_NAMESPACES {
467 let err = refusal(
468 &format!("ns-{ns}"),
469 &format!("[addon]\nnamespace = \"{ns}\"\nmodule = \"echo.js\"\n"),
470 );
471 assert!(err.contains("taken by the runtime"), "{err}");
472 }
473 }
474
475 #[test]
476 fn an_async_function_returns_nothing() {
477 let err = refusal(
478 "async-returns",
479 "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \
480 \"later\"\nreturns = \"int\"\nasync = \"on_echo\"\n",
481 );
482 assert!(err.contains("drop `returns`"), "{err}");
483 }
484
485 #[test]
486 fn a_parameter_is_written_name_colon_type() {
487 let err = refusal(
488 "param",
489 "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \
490 \"shout\"\nparams = [\"string\"]\n",
491 );
492 assert!(err.contains("must be written `name: type`"), "{err}");
493 }
494
495 #[test]
496 fn a_built_in_tag_cannot_be_an_addon_element() {
497 let err = refusal(
498 "tag",
499 "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[element]]\ntag = \
500 \"button\"\nhtml = \"div\"\n",
501 );
502 assert!(err.contains("built-in tag"), "{err}");
503 }
504
505 #[test]
506 fn a_module_without_a_web_directory_has_no_web_half() {
507 let dir = package("no-web", &["echo.js", "lumen-addon.toml"]);
508 assert_eq!(web_half(&dir), None);
510 assert_eq!(web_half(&dir.join("nowhere")), None);
511 let err = read_web_half("echo", &dir).unwrap_err();
512 assert!(err.contains("the web half of 'echo': read"), "{err}");
513 }
514
515 #[test]
516 fn a_descriptor_describes_its_surface_without_its_files() {
517 let addon = describe("echo", ECHO).unwrap();
519 assert_eq!(addon.name, "echo");
520 assert_eq!(addon.namespace, "echo");
521 let names: Vec<&str> = addon.functions.iter().map(|f| f.name.as_str()).collect();
522 assert_eq!(names, ["shout", "later"]);
523 assert_eq!(addon.elements[0].tag, "echo-view");
524 let err = describe(
525 "echo",
526 "[addon]\nnamespace = \"lumen\"\nmodule = \"x.js\"\n",
527 )
528 .unwrap_err();
529 assert!(err.contains("taken by the runtime"), "{err}");
530 }
531
532 #[test]
533 fn an_unknown_key_is_refused() {
534 let err = refusal(
535 "unknown",
536 "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\nversion = \"1\"\n",
537 );
538 assert!(err.contains("unknown field `version`"), "{err}");
539 }
540}