1use std::collections::BTreeMap;
16use std::fmt::Write as _;
17
18use kui_core::schema::{self, Kind};
19
20use crate::PRELUDE;
21
22struct Alias {
27 name: &'static str,
28 doc: &'static str,
29 ty: &'static str,
30 #[cfg_attr(not(test), allow(dead_code))]
31 strings: &'static [&'static str],
32}
33
34const ALIASES: &[Alias] = &[
37 Alias {
38 name: "kui.Color",
39 doc: "A colour: `0xRRGGBBAA`, `\"#hex\"`, or a `\"$token\"` the host declared.",
40 ty: "integer|string",
41 strings: &["\"#336699\"", "\"#336699cc\"", "\"$accent\""],
42 },
43 Alias {
44 name: "kui.Length",
45 doc: "A length in logical px, or a `\"$token\"`.",
46 ty: "number|string",
47 strings: &["\"$gap\""],
48 },
49 Alias {
50 name: "kui.SizeArg",
51 doc: "A size expression's part: px, a spelling (`\"80%\"`, `\"min(…)\"`), `{ pct = n }` or `{ px = n }`.",
52 ty: "number|string|{ pct: number }|{ px: number }",
53 strings: &["\"80%\""],
54 },
55 Alias {
56 name: "kui.Size",
57 doc: "A size expression (backlog F109), resolved against the parent's content box: px, `\"Npx\"`, `\"N%\"`, `\"min(…)\"`, `\"max(…)\"`, `\"clamp(MIN, TARGET, MAX)\"`, nested — or the same as data, which is never parsed: `{ pct = n }`, `{ px = n }`, `{ min = { … } }`, `{ max = { … } }`, `{ clamp = { MIN, TARGET, MAX } }` (a part a table again, or a spelling). The process keeps 65 536 distinct expressions and never lets one go: past that a new one leaves its prop at its default, with a `size-expressions-full` warning — declare one per layout, not one per frame (backlog RG93).",
58 ty: "kui.SizeArg|{ min: kui.SizeArg[] }|{ max: kui.SizeArg[] }|{ clamp: kui.SizeArg[] }",
59 strings: &["\"clamp(400px, 80%, 1000px)\""],
60 },
61 Alias {
62 name: "kui.Sizing",
63 doc: "A sizing: px, `\"fit\"`, `\"grow\"`, `\"N%\"`, `{ grow = n }`, `{ pct = n }` (n percent), a size expression (`kui.Size`), or a `\"$length\"`.",
64 ty: "\"fit\"|\"grow\"|{ grow: number }|kui.Size",
65 strings: &["\"50%\"", "\"$gap\"", "\"clamp(400px, 80%, 1000px)\""],
66 },
67 Alias {
68 name: "kui.Min",
69 doc: "A lower clamp: px, `\"fit\"`, a size expression (`kui.Size`), or a `\"$length\"`.",
70 ty: "\"fit\"|kui.Size",
71 strings: &["\"$gap\"", "\"50%\""],
72 },
73 Alias {
74 name: "kui.Max",
75 doc: "An upper clamp: px, a size expression (`kui.Size`), or a `\"$length\"`.",
76 ty: "kui.Size",
77 strings: &["\"$gap\"", "\"min(720px, 100%)\""],
78 },
79];
80
81const LUA_KEYWORDS: &[&str] = &[
86 "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in",
87 "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while",
88];
89
90const UNIFORM_LIST: &[(&str, bool, &str, &str)] = &[
94 (
95 "key",
96 true,
97 "string",
98 "The container's key, which its scroll geometry is read back by.",
99 ),
100 (
101 "row_h",
102 true,
103 "number",
104 "One row's height: the whole stride, spacing included.",
105 ),
106 (
107 "rows",
108 false,
109 "number",
110 "How many rows the list has; none when left out.",
111 ),
112 (
113 "overscan",
114 false,
115 "number",
116 "Rows built past each end of the viewport; two when left out.",
117 ),
118 (
119 "row_props",
120 false,
121 "fun(i: integer): kui.Props",
122 "Each row's own node's props — its click, stripe, hover; its `index` and `height` stay the list's.",
123 ),
124];
125
126const LIST: &[(&str, bool, &str, &str)] = &[
129 (
130 "key",
131 true,
132 "string",
133 "The container's key, which its scroll geometry is read back by.",
134 ),
135 (
136 "heights",
137 true,
138 "kui.RowHeights",
139 "The heights the list slices by, made once with `row_heights` and kept.",
140 ),
141 (
142 "overscan",
143 false,
144 "number",
145 "Rows built past each end of the viewport; two when left out.",
146 ),
147];
148
149const ROW_HEIGHTS: &[(&str, &str, &str)] = &[
152 ("len", "", "integer"),
153 ("set_len", "rows: integer", "nil"),
154 ("clear", "", "nil"),
155 ("set", "i: integer, h: number", "nil"),
156 ("measured", "i: integer", "number?"),
157 ("get", "i: integer", "number"),
158 ("estimate", "", "number"),
159 ("total", "", "number"),
160 ("offset_of", "i: integer", "number"),
161 ("row_at", "y: number", "integer"),
162];
163
164pub fn luals_meta() -> String {
174 let mut out = String::new();
175 out.push_str(
176 "---@meta kui\n\
177 -- kui-lua's view DSL for lua-language-server, generated from\n\
178 -- kui-core's schema by `kui_lua::luals_meta()`. Regenerate it\n\
179 -- rather than editing it.\n\n",
180 );
181 for a in ALIASES {
182 let _ = writeln!(out, "---{}\n---@alias {} {}", a.doc, a.name, a.ty);
183 }
184 out.push('\n');
185
186 out.push_str(
188 "---Every prop a node table may carry; its children at 1, 2, ….\n---@class kui.Props\n",
189 );
190 for (name, ty, doc) in props_fields() {
191 field(&mut out, name, &ty, doc);
192 }
193 out.push_str("---@field [integer] kui.Node|string\n\n");
194 out.push_str("---A node: what a constructor returns and a view gives back.\n---@class kui.Node: kui.Props\n---@field type string\n\n");
195
196 let mut class_of: BTreeMap<&str, String> = BTreeMap::new();
198 for e in schema::ELEMENTS {
199 let class = format!("kui.{}", e.name);
200 if e.lua_own.is_empty() {
201 for ctor in constructors(e.lua) {
202 class_of.insert(ctor, "kui.Props".into());
203 }
204 continue;
205 }
206 let _ = writeln!(out, "---@class {class}: kui.Props");
207 for own in e.lua_own {
208 field(&mut out, own, "any", e.doc);
209 }
210 out.push('\n');
211 for ctor in constructors(e.lua) {
212 class_of.insert(ctor, class.clone());
213 }
214 }
215
216 out.push_str("---What `uniform_list` reads off its options; every other key is the container's.\n---@class kui.UniformList: kui.Props\n");
218 for (name, required, ty, doc) in UNIFORM_LIST {
219 let opt = if *required { "" } else { "?" };
220 let _ = writeln!(out, "---@field {name}{opt} {ty} {doc}");
221 }
222 out.push('\n');
223
224 out.push_str("---What `list` reads off its options; every other key is the container's.\n---@class kui.List: kui.Props\n");
226 for (name, required, ty, doc) in LIST {
227 let opt = if *required { "" } else { "?" };
228 let _ = writeln!(out, "---@field {name}{opt} {ty} {doc}");
229 }
230 out.push_str(
231 "\n---A variable-height list's row heights: measured where known, the mean of those elsewhere (rows are 0-based).\n---@class kui.RowHeights\n",
232 );
233 for (name, params, ret) in ROW_HEIGHTS {
234 let _ = writeln!(
235 out,
236 "---@field {name} fun(self: kui.RowHeights{}{params}): {ret}",
237 if params.is_empty() { "" } else { ", " }
238 );
239 }
240 out.push_str(
241 "\n---`rows` rows, none measured, each at `estimate` logical px (20 when left out) until measured.\n---@param rows integer\n---@param estimate? number\n---@return kui.RowHeights\nfunction row_heights(rows, estimate) end\n\n",
242 );
243
244 out.push_str(
246 "---An event a view hears: its payload's fields beside `kind`.\n---@class kui.Event\n",
247 );
248 let kinds: Vec<String> = schema::EVENTS
249 .iter()
250 .map(|e| format!("\"{}\"", e.kind))
251 .collect();
252 let _ = writeln!(out, "---@field kind {}", kinds.join("|"));
253 out.push_str("---@field node_key? integer\n---@field [string] any\n\n");
254
255 for f in prelude_functions() {
260 for line in f.doc.lines() {
261 let _ = writeln!(out, "---{line}");
262 }
263 let element = class_of.get(f.name.as_str()).cloned();
264 for p in &f.params {
265 let (opt, ty) = match p.as_str() {
266 "opts" if f.name == "uniform_list" => ("", "kui.UniformList".into()),
267 "opts" if f.name == "list" => ("", "kui.List".into()),
268 "measure" => ("", "fun(i: integer, width: number): number".into()),
269 "t" | "opts" => (
270 if tolerates_nil(&f.body, p) { "?" } else { "" },
271 element.as_deref().unwrap_or("kui.Props").to_string(),
272 ),
273 "s" => ("", "string|number|table".into()),
275 "env" => ("", "table".into()),
276 "row" => ("", "fun(i: integer): kui.Node".into()),
277 "key" => ("", "string|integer".into()),
278 "i" => ("", "integer".into()),
279 "row_h" => ("", "number".into()),
280 _ => ("", "any".into()),
281 };
282 let _ = writeln!(out, "---@param {p}{opt} {ty}");
283 }
284 let ret = match f.name.as_str() {
286 "reveal_row" => "boolean",
287 "rows_in_view" => "integer",
288 _ => "kui.Node",
289 };
290 let _ = writeln!(
291 out,
292 "---@return {ret}\nfunction {}({}) end\n",
293 f.name,
294 f.params.join(", ")
295 );
296 }
297 out
298}
299
300fn props_fields() -> Vec<(&'static str, String, &'static str)> {
304 let mut out = Vec::new();
305 for p in schema::PROPS {
306 for name in lua_names(p.snake_name()) {
307 out.push((name, type_of(&p.kind), p.doc));
308 }
309 }
310 for c in schema::CUSTOM {
311 for name in c.lua_names {
312 out.push((*name, composite_type(name), c.doc));
313 }
314 }
315 out
316}
317
318fn lua_names(snake: &'static str) -> Vec<&'static str> {
322 let mut names: Vec<&'static str> = Vec::new();
323 if !LUA_KEYWORDS.contains(&snake) {
324 names.push(snake);
325 }
326 names.extend(
327 schema::LUA_ALIASES
328 .iter()
329 .filter(|(_, real)| *real == snake)
330 .map(|(alias, _)| *alias),
331 );
332 names
333}
334
335fn composite_type(name: &str) -> String {
339 match name {
340 "size" => "kui.Length".into(),
341 "pad" => "kui.Length|{ all?: kui.Length, x?: kui.Length, y?: kui.Length, l?: kui.Length, r?: kui.Length, t?: kui.Length, b?: kui.Length }".into(),
342 "border" => "{ w?: kui.Length, color?: kui.Color }".into(),
343 "float" => {
344 let presets: Vec<String> = kui_core::FLOAT_PRESETS
345 .iter()
346 .map(|p| format!("\"{p}\""))
347 .collect();
348 format!("{}|table", presets.join("|"))
349 }
350 "key" | "tooltip" | "window_title" => "string".into(),
351 "index" | "row_count" => "number".into(),
352 "windows" => "table".into(),
353 "option_as_alt" => {
354 let names: Vec<String> = kui_core::OptionAsAlt::ALL
355 .iter()
356 .map(|v| format!("\"{}\"", v.name()))
357 .collect();
358 names.join("|")
359 }
360 "clip" | "scroll" | "scroll_x" | "scroll_y" | "key_focus" | "always_on_top"
361 | "secure_input" | "ime_off" => "boolean".into(),
362 _ => "any".into(),
363 }
364}
365
366fn field(out: &mut String, name: &str, ty: &str, doc: &str) {
368 let doc = doc.split_whitespace().collect::<Vec<_>>().join(" ");
369 let _ = writeln!(out, "---@field {name}? {ty} {doc}");
370}
371
372fn type_of(kind: &Kind) -> String {
374 match kind {
375 Kind::F32 => "kui.Length".into(),
376 Kind::Color => "kui.Color".into(),
377 Kind::Flag => "boolean".into(),
378 Kind::Enum(names) => names
379 .iter()
380 .map(|n| format!("\"{n}\""))
381 .collect::<Vec<_>>()
382 .join("|"),
383 Kind::Sizing => "kui.Sizing".into(),
384 Kind::Min => "kui.Min".into(),
385 Kind::Max => "kui.Max".into(),
386 Kind::Msg | Kind::Tag => "any".into(),
387 Kind::Str => "string".into(),
388 Kind::Family => "\"sans\"|\"serif\"|\"mono\"|string".into(),
390 Kind::Resource => "integer".into(),
391 Kind::Keyframes | Kind::Enter | Kind::Gradient => "table".into(),
392 }
393}
394
395fn constructors(lua: &str) -> Vec<&str> {
398 lua.split('`')
399 .skip(1)
400 .step_by(2)
401 .filter_map(|form| {
402 let end = form.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))?;
403 let rest = form[end..].trim_start();
404 (end > 0 && (rest.starts_with('{') || rest.starts_with('('))).then(|| &form[..end])
405 })
406 .collect()
407}
408
409struct PreludeFn {
411 name: String,
412 params: Vec<String>,
413 doc: String,
415 body: String,
417}
418
419fn tolerates_nil(body: &str, p: &str) -> bool {
422 body.contains(&format!("{p} = {p} or ")) || body.contains(&format!("if {p} then"))
423}
424
425fn prelude_functions() -> Vec<PreludeFn> {
427 let mut out: Vec<PreludeFn> = Vec::new();
428 let mut doc: Vec<&str> = Vec::new();
429 let mut open = false;
430 for line in PRELUDE.lines() {
431 if open {
432 if line == "end" {
433 open = false;
434 } else if let Some(f) = out.last_mut() {
435 f.body.push_str(line);
436 f.body.push('\n');
437 }
438 continue;
439 }
440 if let Some(c) = line.strip_prefix("--") {
441 doc.push(c.strip_prefix(' ').unwrap_or(c));
442 continue;
443 }
444 if let Some(sig) = line.strip_prefix("function ")
445 && let Some((name, rest)) = sig.split_once('(')
446 && let Some((params, _)) = rest.split_once(')')
447 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
448 {
449 let params = params
450 .split(',')
451 .map(|p| p.trim().to_string())
452 .filter(|p| !p.is_empty())
453 .collect();
454 out.push(PreludeFn {
455 name: name.to_string(),
456 params,
457 doc: doc.join("\n"),
458 body: String::new(),
459 });
460 open = true;
461 }
462 doc.clear();
463 }
464 out
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use mlua::{Lua, Table};
471
472 #[test]
475 fn the_meta_covers_the_schema_and_the_prelude() {
476 let meta = luals_meta();
477 assert!(meta.starts_with("---@meta kui\n"));
478 for p in schema::PROPS {
479 let names = lua_names(p.snake_name());
480 assert!(!names.is_empty(), "{} has no Lua spelling", p.snake_name());
481 for name in names {
482 assert!(
483 meta.contains(&format!("---@field {name}? ")),
484 "{name} missing"
485 );
486 }
487 }
488 assert!(meta.contains("---@field direction? "));
490 assert!(!meta.contains("---@field repeat? "));
491 for f in prelude_functions() {
492 assert!(
493 meta.contains(&format!("function {}(", f.name)),
494 "{} missing",
495 f.name
496 );
497 }
498 assert!(meta.contains("---@param t? kui.Props\n---@return kui.Node\nfunction row(t) end"));
499 assert!(meta.contains("---@param t kui.edit\n"), "edit's own props");
500 assert!(meta.contains("---@param opts kui.UniformList\n"));
501 assert!(meta.contains("---@field initial? any"));
502 assert_eq!(constructors("`row { }`, `column { }`"), ["row", "column"]);
503 assert_eq!(constructors("`text(\"s\", {…})`"), ["text"]);
504 assert!(constructors("`size`").is_empty());
505 }
506
507 fn prelude_lua() -> Lua {
509 let lua = Lua::new();
510 lua.load(PRELUDE).exec().unwrap();
511 lua
512 }
513
514 fn parses(lua: &Lua, props: &str) -> Result<(), String> {
518 let t: Table = lua
519 .load(format!("return {{ {props} }}"))
520 .eval()
521 .map_err(|e| format!("does not compile: {e}"))?;
522 thread_local! {
525 static CORE: &'static kui_core::Core = Box::leak(Box::new(kui_core::Core::new()));
526 }
527 let core: &'static kui_core::Core = CORE.with(|c| *c);
528 let mut refs = crate::Refs::new(core.token_lookup());
529 crate::parse_props(&t, false, &mut refs)
530 .map(|_| ())
531 .map_err(|e| e.to_string())
532 }
533
534 const ROOT_ONLY: &[&str] = &[
538 "window_title",
539 "always_on_top",
540 "secure_input",
541 "option_as_alt",
542 "ime_off",
543 "windows",
544 ];
545
546 fn members(ty: &str) -> Vec<&str> {
548 let mut out = Vec::new();
549 let (mut depth, mut start) = (0, 0);
550 for (i, c) in ty.char_indices() {
551 match c {
552 '{' | '(' => depth += 1,
553 '}' | ')' => depth -= 1,
554 '|' if depth == 0 => {
555 out.push(ty[start..i].trim());
556 start = i + 1;
557 }
558 _ => {}
559 }
560 }
561 out.push(ty[start..].trim());
562 out
563 }
564
565 fn alias(name: &str) -> Option<&'static Alias> {
566 ALIASES.iter().find(|a| a.name == name)
567 }
568
569 fn shape(member: &str) -> Vec<(&str, bool, &str)> {
571 let inner = member.trim_start_matches('{').trim_end_matches('}');
572 let mut out = Vec::new();
573 let (mut depth, mut start) = (0, 0);
574 let mut fields = Vec::new();
575 for (i, c) in inner.char_indices() {
576 match c {
577 '{' | '(' => depth += 1,
578 '}' | ')' => depth -= 1,
579 ',' if depth == 0 => {
580 fields.push(&inner[start..i]);
581 start = i + 1;
582 }
583 _ => {}
584 }
585 }
586 fields.push(&inner[start..]);
587 for f in fields {
588 let Some((name, ty)) = f.split_once(':') else {
589 continue;
590 };
591 let name = name.trim();
592 let (name, required) = match name.strip_suffix('?') {
593 Some(n) => (n, false),
594 None => (name, true),
595 };
596 out.push((name, required, ty.trim()));
597 }
598 out
599 }
600
601 fn samples(ty: &str, strings: &[&str]) -> Vec<String> {
605 let mut out = Vec::new();
606 for m in members(ty) {
607 match m {
608 "number" => out.push("12.5".into()),
609 "integer" => out.push("3".into()),
610 "boolean" => out.push("true".into()),
611 "string" if strings.is_empty() => out.push("\"x\"".into()),
612 "string" => out.extend(strings.iter().map(|s| s.to_string())),
613 "any" | "table" => {}
614 m if m.starts_with('"') => out.push(m.into()),
615 m if m.starts_with('{') => {
616 let fields: Vec<String> = shape(m)
617 .into_iter()
618 .map(|(name, _, ty)| {
619 let v = samples(ty, &[]).into_iter().next().unwrap();
620 format!("{name} = {v}")
621 })
622 .collect();
623 out.push(format!("{{ {} }}", fields.join(", ")));
624 }
625 m if m.ends_with("[]") => {
628 let one = samples(&m[..m.len() - 2], &[]).into_iter().next().unwrap();
629 out.push(format!("{{ {one}, {one}, {one} }}"));
630 }
631 m if m.starts_with("kui.") => {
632 let a = alias(m).unwrap_or_else(|| panic!("{m} is no alias"));
633 out.extend(samples(a.ty, a.strings));
634 }
635 m => panic!("no sample for {m}"),
636 }
637 }
638 out
639 }
640
641 #[test]
646 fn every_annotated_type_parses() {
647 let lua = Lua::new();
648 for c in schema::CUSTOM {
649 for name in c.lua_names {
650 assert_ne!(
651 composite_type(name),
652 "any",
653 "{name}: a composite the meta does not type"
654 );
655 }
656 }
657 let mut failed = Vec::new();
658 for (name, ty, _) in props_fields() {
659 if ROOT_ONLY.contains(&name) {
660 continue;
661 }
662 for v in samples(&ty, &[]) {
663 if let Err(e) = parses(&lua, &format!("{name} = {v}")) {
664 failed.push(format!("{name} = {v} ({ty}): {e}"));
665 }
666 }
667 }
668 assert!(failed.is_empty(), "{}", failed.join("\n"));
669 }
670
671 enum Probe {
673 Int,
674 Float,
675 Bool,
676 Str(&'static str),
677 Table(&'static [&'static str]),
678 }
679
680 const PROBES: &[(&str, Probe)] = &[
681 ("3", Probe::Int),
682 ("12.5", Probe::Float),
683 ("false", Probe::Bool),
684 ("\"x\"", Probe::Str("x")),
685 ("\"$gap\"", Probe::Str("$gap")),
686 ("\"#336699\"", Probe::Str("#336699")),
687 ("\"50%\"", Probe::Str("50%")),
688 ("\"fit\"", Probe::Str("fit")),
689 ("\"grow\"", Probe::Str("grow")),
690 ("\"below\"", Probe::Str("below")),
691 ("{}", Probe::Table(&[])),
692 ("{ grow = 2 }", Probe::Table(&["grow"])),
693 ("{ pct = 50 }", Probe::Table(&["pct"])),
694 ("{ percent = 50 }", Probe::Table(&["percent"])),
695 ];
696
697 fn admits(ty: &str, p: &Probe) -> bool {
701 members(ty).into_iter().any(|m| match (m, p) {
702 ("any", _) => true,
703 ("table", Probe::Table(_)) => true,
704 ("number" | "integer", Probe::Int | Probe::Float) => true,
705 ("boolean", Probe::Bool) => true,
706 ("string", Probe::Str(_)) => true,
707 (m, Probe::Str(s)) if m.starts_with('"') => m == format!("\"{s}\""),
708 (m, Probe::Table(keys)) if m.starts_with('{') => shape(m)
709 .iter()
710 .all(|(name, required, _)| !required || keys.contains(name)),
711 (m, p) if m.starts_with("kui.") => alias(m).is_some_and(|a| admits(a.ty, p)),
712 _ => false,
713 })
714 }
715
716 #[test]
724 fn every_parsed_value_is_admitted() {
725 let lua = Lua::new();
726 let mut failed = Vec::new();
727 for (name, ty, _) in props_fields() {
728 if ty == "boolean" || ROOT_ONLY.contains(&name) {
729 continue;
730 }
731 for (src, p) in PROBES {
732 if parses(&lua, &format!("{name} = {src}")).is_ok() && !admits(&ty, p) {
733 failed.push(format!("{name} = {src} parses; {ty} refuses it"));
734 }
735 }
736 }
737 assert!(failed.is_empty(), "{}", failed.join("\n"));
738 }
739
740 #[test]
743 fn the_keywords_are_lua_s() {
744 let lua = Lua::new();
745 for k in LUA_KEYWORDS {
746 assert!(
747 lua.load(format!("return {{ {k} = 1 }}")).exec().is_err(),
748 "{k} compiles as a key"
749 );
750 }
751 assert!(lua.load("return { direction = 1 }").exec().is_ok());
752 }
753
754 #[test]
758 fn a_param_is_optional_where_the_prelude_takes_nil() {
759 let lua = prelude_lua();
760 let meta = luals_meta();
761 let mut checked = 0;
762 for f in prelude_functions() {
763 for p in f.params.iter().filter(|p| *p == "t" || *p == "opts") {
764 let args: Vec<&str> = f
765 .params
766 .iter()
767 .map(|q| match q.as_str() {
768 q if q == p => "nil",
769 "s" => "\"x\"",
770 "env" => "{ scroll_geometry = function() end, viewport_h = 100 }",
771 "row" => "function(i) return text(tostring(i)) end",
772 _ => "nil",
773 })
774 .collect();
775 let src = format!("return {}({})", f.name, args.join(", "));
776 let takes_nil = lua.load(&src).exec().is_ok();
777 let optional = section(&meta, &f.name).contains(&format!("---@param {p}? "));
778 assert_eq!(
779 optional,
780 takes_nil,
781 "{}: `{p}` marked {} but the prelude {} nil",
782 f.name,
783 if optional { "optional" } else { "required" },
784 if takes_nil { "takes" } else { "refuses" }
785 );
786 checked += 1;
787 }
788 }
789 assert!(section(&meta, "row").contains("---@param t? "));
791 assert!(section(&meta, "button").contains("---@param t kui.button"));
792 assert!(checked > 20, "{checked} parameters");
793 assert!(lua.load("return text(3), tooltip(3)").exec().is_ok());
796 assert!(section(&meta, "text").contains("---@param s string|number|table\n"));
797 }
798
799 fn section<'a>(meta: &'a str, name: &str) -> &'a str {
802 let end = meta
803 .find(&format!("\nfunction {name}("))
804 .unwrap_or_else(|| panic!("{name} missing"));
805 let start = meta[..end].rfind("\n\n").map_or(0, |i| i + 2);
806 &meta[start..end]
807 }
808
809 #[test]
813 fn uniform_list_s_class_is_what_it_reads() {
814 let f = prelude_functions()
815 .into_iter()
816 .find(|f| f.name == "uniform_list")
817 .unwrap();
818 let reads: Vec<&str> = f
819 .body
820 .split("opts.")
821 .skip(1)
822 .map(|r| {
823 let end = r
824 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
825 .unwrap_or(r.len());
826 &r[..end]
827 })
828 .collect();
829 let props: Vec<&str> = props_fields().into_iter().map(|(n, _, _)| n).collect();
830 for (name, _, _, _) in UNIFORM_LIST {
831 assert!(reads.contains(name), "{name} is never read");
832 }
833 for r in &reads {
834 assert!(
835 props.contains(r) || UNIFORM_LIST.iter().any(|(n, ..)| n == r),
836 "opts.{r} is read and typed nowhere"
837 );
838 }
839 let lua = prelude_lua();
840 let env = "{ scroll_geometry = function() end, viewport_h = 100 }";
841 for (name, required, _, _) in UNIFORM_LIST {
842 let opts: Vec<String> = UNIFORM_LIST
843 .iter()
844 .filter(|(n, ..)| n != name)
845 .map(|(n, _, ty, _)| {
846 let v = if *ty == "string" {
847 "\"log\""
848 } else if ty.starts_with("fun") {
849 "function() return {} end"
850 } else {
851 "4"
852 };
853 format!("{n} = {v}")
854 })
855 .collect();
856 let src = format!(
857 "return uniform_list({env}, {{ {} }}, function(i) return text(i) end)",
858 opts.join(", ")
859 );
860 assert_eq!(
861 lua.load(&src).exec().is_err(),
862 *required,
863 "uniform_list without {name}"
864 );
865 }
866 for pad in ["8", "{ t = 8 }", "{ y = 8 }", "{ all = 8 }", "\"$gap\""] {
870 let src = format!(
871 "return uniform_list({env}, {{ key = \"log\", row_h = 4, rows = 9, pad = {pad} }}, \
872 function(i) return text(i) end)"
873 );
874 assert!(lua.load(&src).exec().is_ok(), "pad = {pad}");
875 }
876 }
877
878 #[test]
880 fn the_checks_catch_the_first_cut_s_types() {
881 let lua = Lua::new();
882 assert!(parses(&lua, "width = { percent = 50 }").is_err());
884 assert!(parses(&lua, "key = 3").is_err());
885 assert!(parses(&lua, "index = \"x\"").is_err());
886 assert!(parses(&lua, "size = \"$gap\"").is_ok());
888 assert!(!admits("number", &Probe::Str("$gap")));
889 assert!(parses(&lua, "repeat = \"alternate\"").is_err());
891 assert!(parses(&lua, "direction = \"alternate\"").is_ok());
892 }
893}