Skip to main content

denise_forms/
codegen.rs

1//! A form file, as Rust the compiler checks.
2//!
3//! The engine hands back `built.node("who")` and resolves message names through
4//! a `match` on a string. That is right for a kiosk, it is checked when the form
5//! loads, and it is what [`Form::build`](crate::Form::build) does. What it is not
6//! is what Delphi gave you, which was `Button1: TButton` as a field the compiler
7//! knew about.
8//!
9//! This generates that. Point a `build.rs` at a form file and get a struct whose
10//! fields are the form's named nodes and an enum whose variants are the form's
11//! messages:
12//!
13//! ```no_run
14//! // build.rs
15//! fn main() {
16//!     denise_forms::codegen::to_out_dir("forms/settings.dform").unwrap();
17//! }
18//! ```
19//!
20//! ```ignore
21//! // src/main.rs
22//! include!(concat!(env!("OUT_DIR"), "/settings.rs"));
23//!
24//! let form = Settings::build(&mut ui, root)?;
25//! ui.widget_mut::<TextInput<SettingsMessage>>(form.who);   // a field, not a lookup
26//! ```
27//!
28//! **Rename a node in the form and the application stops compiling**, naming the
29//! field that no longer exists. **Add a message to the form and every `match` on
30//! the enum stops compiling**, because it is no longer exhaustive. Both are the
31//! point, and both are what a string lookup cannot do.
32//!
33//! And a trait, `SettingsHandlers`, with one method per message named for it —
34//! `fn save(&mut self)`, `fn set_notify(&mut self, on: bool)` — plus
35//! `SettingsMessage::dispatch`, which calls the method a message is named for.
36//! Implement the trait for whatever handles the form and there is no `match` to
37//! write at all: add an event in the designer and the compiler names the method
38//! that is missing, which is the one the designer writes when asked to open it.
39//!
40//! # A build script rather than a proc macro
41//!
42//! Chosen deliberately, and [#101] said to. The output is a file you can open,
43//! `cargo doc` sees it, a debugger steps through it, and it needs no second
44//! crate. A macro would read a little better at the call site and cost all four.
45//!
46//! # It generates a caller, not a second engine
47//!
48//! The generated `build` calls [`Form::build`](crate::Form::build) with a
49//! generated [`Wiring`](crate::Wiring). There is one implementation of building
50//! a form, and this is a typed door onto it — so a form that loads at runtime and
51//! the same form generated behave identically, because they are the same code.
52//!
53//! [#101]: https://github.com/bisand/denise/issues/101
54
55// The examples here are build scripts, and a build script *is* its `fn main`.
56// Compiling them is what checks these call signatures are real, so they stay
57// doctests rather than becoming prose.
58#![allow(clippy::needless_doctest_main)]
59
60use std::collections::BTreeMap;
61
62use denise_ui::widgets::{Payload, PropertyKind, all};
63
64use crate::error::{At, Error, Reason};
65use crate::form::Form;
66
67/// Rust's keywords, which a form file is free to use as a name and Rust is not.
68///
69/// Escaped as `r#name` rather than refused, because `type` and `match` are
70/// perfectly good names for a field and the raw form is exactly what it is for.
71/// The three that cannot be raw are refused instead.
72const KEYWORDS: &[&str] = &[
73    "as", "async", "await", "break", "const", "continue", "dyn", "else", "enum", "extern", "false",
74    "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
75    "return", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
76    "while", "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try",
77    "typeof", "unsized", "virtual", "yield", "gen",
78];
79
80/// The three keywords Rust will not accept even raw.
81const NEVER_RAW: &[&str] = &["crate", "self", "Self"];
82
83/// What one form generated: the text of a module, and what is in it.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct Generated {
86    /// The Rust source. Write it somewhere and `include!` it.
87    pub source: String,
88    /// The struct's name, taken from the file's own `name=` or its file name.
89    pub kind: String,
90    /// The message enum's name: the struct's, with `Message` on the end.
91    pub message: String,
92}
93
94/// Generates the struct and the enum for a form.
95///
96/// `name` is what the struct is called — the file's stem, usually. See
97/// [`to_out_dir`], which does that part for you.
98///
99/// ```
100/// # use denise_forms::codegen::generate;
101/// let form = r#"form "F" version=1 width=200 height=100 {
102///     text-input name=full-name x=0 y=0 w=100 h=30 on-submit=save
103///     checkbox "Notify" name=notify x=0 y=40 w=100 h=20 on-change=set-notify
104/// }"#;
105///
106/// let generated = generate(form, "settings")?;
107/// assert_eq!(generated.kind, "Settings");
108/// assert_eq!(generated.message, "SettingsMessage");
109///
110/// // A kebab name becomes a snake field and a Pascal variant, and the payload
111/// // the widget needs becomes what the variant carries.
112/// assert!(generated.source.contains("pub full_name: ::denise_ui::NodeId"));
113/// assert!(generated.source.contains("Save,"));
114/// assert!(generated.source.contains("SetNotify(bool)"));
115/// # Ok::<(), denise_forms::Error>(())
116/// ```
117///
118/// # Errors
119///
120/// Everything [`Form::parse`](crate::Form::parse) can say, plus the three things
121/// only generating code can hit: a name that is not a Rust identifier, two names
122/// that become one identifier, and one message name used with two payload shapes.
123/// Each carries the position in the file.
124pub fn generate(source: &str, name: &str) -> Result<Generated, Error> {
125    let form = Form::parse(source)?;
126    let kind = type_name(name).ok_or_else(|| {
127        Error::new(
128            At::START,
129            Reason::NotAnIdentifier {
130                found: name.to_string(),
131                because: "a form's name has to start with a letter",
132            },
133        )
134    })?;
135    let message = format!("{kind}Message");
136
137    // Named nodes become fields; message names become variants. Both are
138    // gathered in file order, so the generated file reads down the form.
139    let mut fields: Vec<(String, String)> = Vec::new();
140    let mut taken: BTreeMap<String, String> = BTreeMap::new();
141    let mut messages: Vec<Message> = Vec::new();
142    let mut seen: BTreeMap<String, (Payload, String)> = BTreeMap::new();
143
144    for node in form.written() {
145        if node.path.is_empty() {
146            continue;
147        }
148        if let Some(name) = &node.name {
149            let field = field_name(name)?;
150            if let Some(first) = taken.get(&field) {
151                return Err(Error::new(
152                    At::START,
153                    Reason::Collides {
154                        found: name.clone(),
155                        with: first.clone(),
156                        spelled: field,
157                    },
158                ));
159            }
160            taken.insert(field.clone(), name.clone());
161            fields.push((field, name.clone()));
162        }
163
164        let Some(info) = all().iter().find(|widget| widget.kind == node.kind) else {
165            continue;
166        };
167        for property in info.properties {
168            let PropertyKind::Message(payload) = property.kind else {
169                continue;
170            };
171            let Some(used) = form.property(&node.path, property.name) else {
172                continue;
173            };
174            match seen.get(&used) {
175                Some((first, _)) if *first != payload => {
176                    return Err(Error::new(
177                        At::START,
178                        Reason::PayloadClash {
179                            found: used,
180                            first: shape(*first),
181                            then: shape(payload),
182                        },
183                    ));
184                }
185                Some(_) => continue,
186                None => {}
187            }
188            let variant = variant_name(&used)?;
189            let method = field_name(&used)?;
190            seen.insert(used.clone(), (payload, variant.clone()));
191            messages.push(Message {
192                variant,
193                method,
194                name: used,
195                payload,
196            });
197        }
198    }
199
200    Ok(Generated {
201        source: write(source, &kind, &message, &fields, &messages),
202        kind,
203        message,
204    })
205}
206
207/// Generates a form into `OUT_DIR` and tells Cargo to watch it.
208///
209/// The whole of a `build.rs`:
210///
211/// ```no_run
212/// fn main() {
213///     denise_forms::codegen::to_out_dir("forms/settings.dform").unwrap();
214/// }
215/// ```
216///
217/// The module lands at `$OUT_DIR/<stem>.rs` and the struct is named after the
218/// stem: `settings.dform` gives `Settings` and `SettingsMessage`.
219///
220/// # Errors
221///
222/// Anything [`generate`] can say, and anything reading or writing a file can.
223pub fn to_out_dir(path: impl AsRef<std::path::Path>) -> Result<std::path::PathBuf, String> {
224    let path = path.as_ref();
225    // Before anything can fail, so a form that stops generating still rebuilds
226    // when it is fixed rather than staying broken until a clean.
227    println!("cargo:rerun-if-changed={}", path.display());
228
229    let stem = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
230        format!(
231            "{}: no file name to take a struct name from",
232            path.display()
233        )
234    })?;
235    let source = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
236    let generated = generate(&source, stem).map_err(|e| format!("{}:{e}", path.display()))?;
237
238    let out = std::path::PathBuf::from(
239        std::env::var("OUT_DIR")
240            .map_err(|_| String::from("OUT_DIR is not set; this is for a build script"))?,
241    )
242    .join(format!("{stem}.rs"));
243    std::fs::write(&out, &generated.source).map_err(|e| format!("{}: {e}", out.display()))?;
244    Ok(out)
245}
246
247/// What a payload is called in a message.
248const fn shape(payload: Payload) -> &'static str {
249    match payload {
250        Payload::None => "the message itself",
251        Payload::Bool => "a `fn(bool)`",
252        Payload::Index => "a `fn(usize)`",
253        Payload::Number => "a `fn(f32)`",
254    }
255}
256
257/// One message the form emits, in the spellings the generated code uses.
258struct Message {
259    /// The enum variant: `SetNotify`.
260    variant: String,
261    /// The handler method: `set_notify`.
262    method: String,
263    /// What the form calls it: `set-notify`.
264    name: String,
265    /// What it carries.
266    payload: Payload,
267}
268
269/// The parameter a handler method takes after `self`, named as the
270/// designer's placeholder names it, so the two are the same text.
271const fn parameter(payload: Payload) -> &'static str {
272    match payload {
273        Payload::None => "",
274        Payload::Bool => ", on: bool",
275        Payload::Index => ", index: usize",
276        Payload::Number => ", value: f32",
277    }
278}
279
280/// The Rust type of a payload, as it appears in a variant.
281const fn carried(payload: Payload) -> &'static str {
282    match payload {
283        Payload::None => "",
284        Payload::Bool => "(bool)",
285        Payload::Index => "(usize)",
286        Payload::Number => "(f32)",
287    }
288}
289
290/// The whole module, as text.
291fn write(
292    source: &str,
293    kind: &str,
294    message: &str,
295    fields: &[(String, String)],
296    messages: &[Message],
297) -> String {
298    let mut out = String::new();
299    out.push_str(
300        "// Generated from a `.dform` file by `denise_forms::codegen`. Do not edit:\n\
301         // the form file is the source, and this is rewritten on every build.\n\n",
302    );
303
304    // The form's own text, so the generated module needs no path at run time.
305    out.push_str(&format!(
306        "/// The form this was generated from, as it stood at build time.\n\
307         pub const {}_SOURCE: &str = r####\"{source}\"####;\n\n",
308        upper(kind),
309    ));
310
311    // The struct.
312    out.push_str(&format!(
313        "/// Every node [`{kind}`] names, as a field.\n\
314         ///\n\
315         /// Rename one in the form and this stops compiling where it was used,\n\
316         /// which is the whole reason this file is generated.\n\
317         #[derive(Clone, Copy, Debug, PartialEq, Eq)]\n\
318         pub struct {kind} {{\n"
319    ));
320    for (field, name) in fields {
321        out.push_str(&format!(
322            "    /// The node the form calls `{name}`.\n    pub {field}: ::denise_ui::NodeId,\n"
323        ));
324    }
325    if fields.is_empty() {
326        out.push_str("    /// The form names no nodes.\n    _private: (),\n");
327    }
328    out.push_str("}\n\n");
329
330    // The message enum.
331    out.push_str(&format!(
332        "/// Every message [`{kind}`] can emit.\n\
333         ///\n\
334         /// Add one to the form and every `match` on this stops compiling,\n\
335         /// because it is no longer exhaustive.\n\
336         #[derive(Clone, Copy, PartialEq, Debug)]\n\
337         pub enum {message} {{\n"
338    ));
339    for Message {
340        variant,
341        name,
342        payload,
343        ..
344    } in messages
345    {
346        out.push_str(&format!(
347            "    /// What the form calls `{name}`.\n    {variant}{},\n",
348            carried(*payload)
349        ));
350    }
351    if messages.is_empty() {
352        out.push_str("    /// The form emits nothing, and this cannot be constructed.\n    #[doc(hidden)]\n    Never,\n");
353    }
354    out.push_str("}\n\n");
355
356    // The handlers trait, and the dispatcher onto it. One step past an
357    // exhaustive `match`: add an event to the form and the compiler does not
358    // say "a match is missing an arm", it says which *method* is missing —
359    // the one the designer writes when asked to open that event.
360    out.push_str(&format!(
361        "/// What [`{kind}`]'s events reach: one method per message, named for it.\n\
362         ///\n\
363         /// Implement this for the type that handles the form, and hand it every\n\
364         /// message through [`{message}::dispatch`]. Add an event to the form and\n\
365         /// this `impl` stops compiling, naming the method it is missing — which\n\
366         /// is the method the designer writes when asked to open that event.\n\
367         pub trait {kind}Handlers {{\n"
368    ));
369    for Message {
370        method,
371        name,
372        payload,
373        ..
374    } in messages
375    {
376        out.push_str(&format!(
377            "    /// What the form calls `{name}`.\n    fn {method}(&mut self{});\n",
378            parameter(*payload)
379        ));
380    }
381    out.push_str("}\n\n");
382    out.push_str(&format!(
383        "impl {message} {{\n\
384         \x20   /// Hands this message to the method named for it.\n\
385         \x20   pub fn dispatch(self, handlers: &mut impl {kind}Handlers) {{\n\
386         \x20       match self {{\n"
387    ));
388    for Message {
389        variant,
390        method,
391        payload,
392        ..
393    } in messages
394    {
395        out.push_str(&match payload {
396            Payload::None => format!("            Self::{variant} => handlers.{method}(),\n"),
397            _ => format!("            Self::{variant}(value) => handlers.{method}(value),\n"),
398        });
399    }
400    if messages.is_empty() {
401        out.push_str("            Self::Never => {}\n");
402    }
403    out.push_str("        }\n    }\n}\n\n");
404
405    // The wiring, and the constructor.
406    out.push_str(&format!(
407        "impl {kind} {{\n\
408         \x20   /// Builds the form under `parent`, with no pictures.\n\
409         \x20   ///\n\
410         \x20   /// # Errors\n\
411         \x20   ///\n\
412         \x20   /// Whatever [`denise_forms::Form::build`] says, with a line and a column.\n\
413         \x20   pub fn build(\n\
414         \x20       ui: &mut ::denise_ui::Ui<{message}>,\n\
415         \x20       parent: ::denise_ui::NodeId,\n\
416         \x20   ) -> ::core::result::Result<Self, ::denise_forms::Error> {{\n\
417         \x20       Self::build_with(ui, parent, &mut |_: &str| None)\n\
418         \x20   }}\n\n\
419         \x20   /// Builds the form, loading pictures through `assets`.\n\
420         \x20   ///\n\
421         \x20   /// # Errors\n\
422         \x20   ///\n\
423         \x20   /// Whatever [`denise_forms::Form::build`] says, with a line and a column.\n\
424         \x20   pub fn build_with(\n\
425         \x20       ui: &mut ::denise_ui::Ui<{message}>,\n\
426         \x20       parent: ::denise_ui::NodeId,\n\
427         \x20       assets: &mut dyn FnMut(&str) -> Option<::denise_forms::Picture>,\n\
428         \x20   ) -> ::core::result::Result<Self, ::denise_forms::Error> {{\n\
429         \x20       let form = ::denise_forms::Form::parse({0}_SOURCE)?;\n\
430         \x20       let fit = ::denise_forms::Placement {{\n\
431         \x20           x: 1.0,\n\
432         \x20           y: 1.0,\n\
433         \x20           rect: ::denise::Rect::from_size(form.size()),\n\
434         \x20       }};\n\
435         \x20       Self::place(ui, parent, fit, assets)\n\
436         \x20   }}\n\n\
437         \x20   /// Builds the form at a [`Placement`](denise_forms::Placement), which is\n\
438         \x20   /// what the file's own `scaling=` works out to.\n\
439         \x20   ///\n\
440         \x20   /// The typed door onto [`Form::build_fitted`](denise_forms::Form::build_fitted),\n\
441         \x20   /// so a generated form scales exactly as a loaded one does.\n\
442         \x20   ///\n\
443         \x20   /// # Errors\n\
444         \x20   ///\n\
445         \x20   /// Whatever [`denise_forms::Form::build_fitted`] says, with a line and a column.\n\
446         \x20   pub fn place(\n\
447         \x20       ui: &mut ::denise_ui::Ui<{message}>,\n\
448         \x20       parent: ::denise_ui::NodeId,\n\
449         \x20       fit: ::denise_forms::Placement,\n\
450         \x20       assets: &mut dyn FnMut(&str) -> Option<::denise_forms::Picture>,\n\
451         \x20   ) -> ::core::result::Result<Self, ::denise_forms::Error> {{\n\
452         \x20       let form = ::denise_forms::Form::parse({0}_SOURCE)?;\n\
453         \x20       let built = form.build_fitted(ui, parent, fit, &mut {kind}Wiring {{ assets }})?;\n\
454         \x20       Ok(Self {{\n",
455        upper(kind),
456    ));
457    for (field, name) in fields {
458        out.push_str(&format!(
459            "            {field}: built.node({name:?}).expect(\"the form names it, and this file was generated from that form\"),\n"
460        ));
461    }
462    if fields.is_empty() {
463        out.push_str("            _private: (),\n");
464    }
465    out.push_str("        })\n    }\n\n");
466
467    // The form's own facts, so a caller needs no second copy of them.
468    out.push_str(&format!(
469        "    /// What the form was designed at, and what it is called.\n\
470         \x20   ///\n\
471         \x20   /// # Panics\n\
472         \x20   ///\n\
473         \x20   /// Never: the source was parsed at build time to generate this.\n\
474         \x20   pub fn form() -> ::denise_forms::Form {{\n\
475         \x20       ::denise_forms::Form::parse({}_SOURCE).expect(\"generated from this very text\")\n\
476         \x20   }}\n}}\n\n",
477        upper(kind),
478    ));
479
480    // The generated resolver: one arm per name, with the shape the widget needs.
481    out.push_str(&format!(
482        "/// Turns the form's message names into [`{message}`].\n\
483         ///\n\
484         /// One arm per name, generated — so a name the form uses and this does\n\
485         /// not answer is impossible rather than an error at load.\n\
486         struct {kind}Wiring<'a> {{\n\
487         \x20   assets: &'a mut dyn FnMut(&str) -> Option<::denise_forms::Picture>,\n\
488         }}\n\n\
489         impl ::denise_forms::Wiring<{message}> for {kind}Wiring<'_> {{\n\
490         \x20   fn message(\n\
491         \x20       &mut self,\n\
492         \x20       name: &str,\n\
493         \x20       payload: ::denise_forms::Payload,\n\
494         \x20   ) -> Option<::denise_forms::Handler<{message}>> {{\n\
495         \x20       Some(match (name, payload) {{\n"
496    ));
497    for Message {
498        variant,
499        name,
500        payload,
501        ..
502    } in messages
503    {
504        let arm = match payload {
505            Payload::None => format!("::denise_forms::Handler::Plain({message}::{variant})"),
506            Payload::Bool => format!("::denise_forms::Handler::Bool({message}::{variant})"),
507            Payload::Index => format!("::denise_forms::Handler::Index({message}::{variant})"),
508            Payload::Number => format!("::denise_forms::Handler::Number({message}::{variant})"),
509        };
510        out.push_str(&format!(
511            "            ({name:?}, ::denise_forms::Payload::{:?}) => {arm},\n",
512            payload
513        ));
514    }
515    out.push_str(
516        "            _ => return None,\n        })\n    }\n\n\
517         \x20   fn asset(&mut self, path: &str) -> Option<::denise_forms::Picture> {\n\
518         \x20       (self.assets)(path)\n    }\n}\n",
519    );
520    out
521}
522
523/// `settings` becomes `SETTINGS`, for the source constant.
524fn upper(kind: &str) -> String {
525    let mut out = String::new();
526    for (index, character) in kind.chars().enumerate() {
527        if character.is_uppercase() && index > 0 {
528            out.push('_');
529        }
530        out.extend(character.to_uppercase());
531    }
532    out
533}
534
535/// `settings-screen` becomes `SettingsScreen`.
536fn type_name(name: &str) -> Option<String> {
537    let mut out = String::new();
538    let mut upper = true;
539    for character in name.chars() {
540        if character == '-' || character == '_' || character == ' ' {
541            upper = true;
542            continue;
543        }
544        if !character.is_ascii_alphanumeric() {
545            return None;
546        }
547        if upper {
548            out.extend(character.to_uppercase());
549            upper = false;
550        } else {
551            out.push(character);
552        }
553    }
554    (!out.is_empty() && out.starts_with(|c: char| c.is_ascii_alphabetic())).then_some(out)
555}
556
557/// `set-notify` becomes `SetNotify`.
558fn variant_name(name: &str) -> Result<String, Error> {
559    type_name(name).ok_or_else(|| {
560        Error::new(
561            At::START,
562            Reason::NotAnIdentifier {
563                found: name.to_string(),
564                because: "a message name becomes an enum variant, so it must be letters, \
565                          digits and dashes, starting with a letter",
566            },
567        )
568    })
569}
570
571/// `full-name` becomes `full_name`, and `type` becomes `r#type`.
572fn field_name(name: &str) -> Result<String, Error> {
573    let refuse = |because| {
574        Err(Error::new(
575            At::START,
576            Reason::NotAnIdentifier {
577                found: name.to_string(),
578                because,
579            },
580        ))
581    };
582    if name.is_empty() {
583        return refuse("a name has to be something");
584    }
585    if NEVER_RAW.contains(&name) {
586        return refuse("Rust will not accept this word as an identifier, even raw");
587    }
588    let mut out = String::new();
589    for character in name.chars() {
590        match character {
591            '-' | ' ' => out.push('_'),
592            c if c.is_ascii_alphanumeric() || c == '_' => out.push(c),
593            _ => return refuse("a field name is letters, digits, dashes and underscores"),
594        }
595    }
596    if !out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
597        return refuse("a field name has to start with a letter or an underscore");
598    }
599    if KEYWORDS.contains(&out.as_str()) {
600        out.insert_str(0, "r#");
601    }
602    Ok(out)
603}