Skip to main content

kui_lua/
meta.rs

1//! The view DSL described for lua-language-server: a `---@meta` file of
2//! the prelude's constructors and the props a node table takes, generated
3//! from kui-core's schema so it cannot fall behind it. A host writes it
4//! somewhere and puts that directory on the server's `workspace.library`;
5//! a script then completes `row {` with its props, and a misspelt one is a
6//! diagnostic where it is typed rather than a warning at runtime.
7//!
8//! What the schema cannot say — the aliases' shapes, the composites'
9//! types, which constructor takes nil — is written here by hand, and
10//! the tests hold each of those against the parser and the prelude
11//! rather than against this file (an earlier cut typed `key` as
12//! `integer|string`, `{ percent = n }` as a sizing and `repeat` as a
13//! field, and every one of them failed at runtime).
14
15use std::collections::BTreeMap;
16use std::fmt::Write as _;
17
18use kui_core::schema::{self, Kind};
19
20use crate::PRELUDE;
21
22/// A type the file names once: its name, its doc, the LuaLS type it
23/// stands for, and the strings its `string` member means — a bare
24/// `string` says less than the parser takes, so the tests sample these
25/// instead of any string.
26struct 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
34/// `parse_color`, `length_of`, `parse_sizing` and the `Kind::Min` /
35/// `Kind::Max` arm of `parse_value`, in LuaLS's words.
36const 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
81/// The words the Lua the prelude runs in reserves (5.5's `global` is
82/// not among them: the vendored build keeps it a name): a prop under
83/// one of these cannot be written `name = …` in a table constructor, so
84/// the file gives only the spelling `schema::LUA_ALIASES` has for it.
85const 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
90/// What `uniform_list` reads off its `opts` beyond a container's
91/// props: each name, whether the prelude refuses to go on without it,
92/// its type and doc.
93const 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
126/// What `list` reads off its `opts` beyond a container's props, as
127/// [`UNIFORM_LIST`] is for `uniform_list`.
128const 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
149/// `row_heights`' methods: what a script calls on the heights it keeps.
150/// The three `slice_*` steps are `list`'s and left out.
151const 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
164/// The `---@meta` file for lua-language-server, as text.
165///
166/// Write it to a directory on the server's `workspace.library` and scripts
167/// get completion and diagnostics for the prelude's builders and every prop.
168///
169/// ```no_run
170/// std::fs::write("lua/kui.lua", kui_lua::luals_meta())?;
171/// # Ok::<(), std::io::Error>(())
172/// ```
173pub 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    // Every prop a node table takes, the schema's and the composites'.
187    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    // An element's own props, on a class of its own.
197    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    // `uniform_list`'s options: a container's props and its own.
217    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    // `list`'s options, and the heights it slices by.
225    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    // The events a view's `on_event` hears.
245    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    // The prelude's constructors, each with the comment above it. A
256    // table parameter is optional only where the body defaults or tests
257    // it before indexing it: `button(nil)` is an error in the prelude, so
258    // it is one where it is typed too.
259    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                // Spans, or anything `tostring` makes the text of.
274                "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        // Most of the prelude builds a node; the two list readings answer.
285        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
300/// Every field of `kui.Props`: its Lua name, type and doc. A schema
301/// row under each name a Lua table can spell it by, a composite under
302/// each of its Lua names.
303fn 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
318/// The names a Lua table writes the schema row `snake` under: the
319/// aliases `schema::LUA_ALIASES` gives it, and its own name unless that
320/// is a keyword no table constructor can hold.
321fn 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
335/// A composite's type, by what `parse_props` (or, for the window's
336/// rows, the root read in `lib.rs`) takes under that name. `any` for a
337/// name this does not know, which the tests refuse.
338fn 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
366/// A field line: its name, type and the doc flattened to one line.
367fn 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
372/// A schema row's type, by the `parse_value` arm its kind takes.
373fn 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        // The stock three, for an editor to offer, or any installed name.
389        Kind::Family => "\"sans\"|\"serif\"|\"mono\"|string".into(),
390        Kind::Resource => "integer".into(),
391        Kind::Keyframes | Kind::Enter | Kind::Gradient => "table".into(),
392    }
393}
394
395/// The constructors an element's `lua` column names: the identifier
396/// opening each backticked form, `row { }` → `row`.
397fn 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
409/// A global function the prelude defines.
410struct PreludeFn {
411    name: String,
412    params: Vec<String>,
413    /// The comment block right above it.
414    doc: String,
415    /// Its lines up to the `end` that closes it at the margin.
416    body: String,
417}
418
419/// Whether a prelude function tolerates `p` being nil: it defaults it
420/// (`t = t or {}`) or tests it (`if opts then`) before indexing it.
421fn tolerates_nil(body: &str, p: &str) -> bool {
422    body.contains(&format!("{p} = {p} or ")) || body.contains(&format!("if {p} then"))
423}
424
425/// Every global function the prelude defines, in its order.
426fn 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    /// Every prop the schema has and every constructor the prelude
473    /// defines is in the file, each constructor typed by its element.
474    #[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        // `repeat` is a keyword: the row is there as `direction` only.
489        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    /// A fresh Lua with the prelude loaded.
508    fn prelude_lua() -> Lua {
509        let lua = Lua::new();
510        lua.load(PRELUDE).exec().unwrap();
511        lua
512    }
513
514    /// `props` through the parser a view's tables go through, over a
515    /// bare core: no tokens declared, so a `$name` misses (a warning, a
516    /// default) rather than failing the parse.
517    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        // One core per thread, leaked: a core is slow to make and the
523        // lookup only borrows it.
524        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    /// The composites the root table carries and `parse_props` never
535    /// reads: the parser takes anything under these, so neither
536    /// direction below says anything about them.
537    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    /// `ty`'s union members, split at the top level only.
547    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    /// A `{ a: T, b?: U }` shape's fields: name, whether required, type.
570    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    /// A Lua value of every member of `ty` the parser can be asked
602    /// about: literals and shapes from the type itself, `string` as the
603    /// enclosing alias means it. `any`, `table` and functions give none.
604    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                // A list: three of its first member, which is what every
626                // list a size takes (`clamp`'s three, `min`'s any) admits.
627                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    /// Every value the file says a prop takes, the parser takes: each
642    /// member of each field's type, sampled, under the field's name —
643    /// which the table constructor compiling also holds to being a name
644    /// Lua can write.
645    #[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    /// A probe value, and what a type has to have to admit it.
672    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    /// Whether `ty` admits `p`. `integer` admits a float: the parser
698    /// truncates one where a colour or a handle goes, and the annotation
699    /// says which the value means rather than every number it survives.
700    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    /// Every value the parser takes for a prop, the file admits: a probe
717    /// set of numbers, strings and table shapes under each field, and a
718    /// probe the parser accepts that the field's type refuses is a type
719    /// narrower than the truth (`size` typed `number` while a `"$token"`
720    /// parses). Flags are left out — anything but `true` is off, so the
721    /// parser takes every value and the annotation says what turns one
722    /// on — as are the root's composites, which it never reads.
723    #[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    /// Each word `LUA_KEYWORDS` lists is one the Lua the prelude runs in
741    /// refuses as a table key, and an ordinary name is not.
742    #[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    /// A table parameter the file marks optional is one the prelude
755    /// really takes nil for, and one it marks required is one nil fails:
756    /// every prelude function with a `t` or `opts`, called with it nil.
757    #[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        // Both answers are exercised: `row` defaults `t`, `button` indexes it.
790        assert!(section(&meta, "row").contains("---@param t? "));
791        assert!(section(&meta, "button").contains("---@param t kui.button"));
792        assert!(checked > 20, "{checked} parameters");
793        // `s` is the text or its spans, and a number is text: `text(i)`
794        // in a row builder is the common case.
795        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    /// The lines the file gives `name`: from the blank line before its
800    /// doc to its `function` line.
801    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    /// `kui.UniformList` is what `uniform_list` reads: every field
810    /// is an `opts.` read in its body, every read is a field or a prop,
811    /// and a required field is one the prelude refuses to go on without.
812    #[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        // Its `pad` is the prop's every shape: it read `pad_t` and
867        // `pad_y`, which no Lua table carries, and did arithmetic on a
868        // table `pad`.
869        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    /// The same checks catch what RG33 found in the first cut.
879    #[test]
880    fn the_checks_catch_the_first_cut_s_types() {
881        let lua = Lua::new();
882        // `{ percent = n }` as a sizing, `key` as a number, `index` as a string.
883        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        // `size` as a bare number while a token parses.
887        assert!(parses(&lua, "size = \"$gap\"").is_ok());
888        assert!(!admits("number", &Probe::Str("$gap")));
889        // `repeat` as a field name.
890        assert!(parses(&lua, "repeat = \"alternate\"").is_err());
891        assert!(parses(&lua, "direction = \"alternate\"").is_ok());
892    }
893}