yog-event 0.8.0

Yog event system — pub/sub for game/server events.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! The bus's messages for Java: a record per event (`dev.yog.bus.YogEvents`)
//! and per service (`YogServices`), from the same schemas, that encodes
//! itself and goes on the bus — the host fires events and calls services
//! through these instead of a JNI method each, and reads the replies.

mod events;
mod frames;
mod services;
mod types;
mod views;
mod words;

use std::fmt::Write;

use yog_data::Shape;
use yog_data::schema::StructShape;

use types::{TYPES, from_data, java_type, nullable, same, to_data};

/// What the generated file says it is.
const HEADER: &str = "Generated by yog-event from Yog-Mod-Loader/rust/crates/yog-event — don't edit; regenerate with `cargo run -p yog-event --bin yog-event-export`.";

/// The imports, each added when the file uses it. Java's own types are
/// written in full (`java.util.List`): a message may be named `List`.
const IMPORTS: &[(&str, &str)] = &[
    ("YogBus.", "dev.yog.YogBus"),
    ("YogData.", "dev.yog.data.YogData"),
    ("YogFrame.", "dev.yog.data.YogFrame"),
    ("YogMessage", "dev.yog.YogMessage"),
];

/// One level of indentation.
const INDENT: &str = "    ";

/// `player_name` → `playerName`.
fn camel(snake: &str) -> String {
    let mut out = String::new();
    let mut upper = false;
    for c in snake.chars() {
        if c == '_' {
            upper = true;
        } else if upper {
            out.extend(c.to_uppercase());
            upper = false;
        } else {
            out.push(c);
        }
    }
    out
}

/// Java's reserved words: a field named like one gets a `_` after it.
const JAVA_KEYWORDS: &[&str] = &[
    "abstract",
    "assert",
    "boolean",
    "break",
    "byte",
    "case",
    "catch",
    "char",
    "class",
    "const",
    "continue",
    "default",
    "do",
    "double",
    "else",
    "enum",
    "extends",
    "final",
    "finally",
    "float",
    "for",
    "goto",
    "if",
    "implements",
    "import",
    "instanceof",
    "int",
    "interface",
    "long",
    "native",
    "new",
    "package",
    "private",
    "protected",
    "public",
    "return",
    "short",
    "static",
    "strictfp",
    "super",
    "switch",
    "synchronized",
    "this",
    "throw",
    "throws",
    "transient",
    "try",
    "void",
    "volatile",
    "while",
    "true",
    "false",
    "null",
    "record",
    "var",
    "yield",
];

/// A payload field's Java name: `player_name` → `playerName`, `interface` → `interface_`.
fn field(snake: &str) -> String {
    let name = camel(snake);
    if JAVA_KEYWORDS.contains(&name.as_str()) {
        format!("{name}_")
    } else {
        name
    }
}

/// `key_binding` → `KeyBinding`.
pub(super) fn class_name(segment: &str) -> String {
    let mut out = camel(segment);
    if let Some(first) = out.get(..1) {
        out.replace_range(..1, &first.to_uppercase());
    }
    out
}

/// The file being written, and the payload records it still owes.
#[derive(Default)]
struct Out {
    text: String,
    records: Vec<StructShape>,
    /// The class's own members, written before its nested ones.
    members: String,
}

/// A record with `s`'s fields: `toData()` builds the payload, `fromData` /
/// `fromBytes` read one back; `body` adds the message's methods.
fn record(o: &mut Out, pad: &str, name: &str, s: &StructShape, body: &str) {
    let Out {
        text: out, records, ..
    } = o;
    let params: Vec<String> = s
        .fields
        .iter()
        .map(|f| format!("{} {}", java_type(&(f.shape)(), records), field(f.name)))
        .collect();
    if !s.doc.is_empty() {
        let _ = writeln!(out, "\n{pad}/** {} */", s.doc);
    }
    let _ = writeln!(out, "{pad}public record {name}({}) {{", params.join(", "));
    let _ = writeln!(
        out,
        "{pad}    public java.util.Map<String, Object> toData() {{\n{pad}        java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();"
    );
    for f in &s.fields {
        let shape = (f.shape)();
        let field = field(f.name);
        let line = match nullable(&shape) {
            Some(inner) => format!(
                "if ({field} != null) m.put(\"{}\", {});",
                f.name,
                to_data(&inner, &field, 0)
            ),
            None => format!("m.put(\"{}\", {});", f.name, to_data(&shape, &field, 0)),
        };
        let _ = writeln!(out, "{pad}        {line}");
    }
    let _ = writeln!(out, "{pad}        return m;\n{pad}    }}\n");
    let args: Vec<String> = s
        .fields
        .iter()
        .map(|f| from_data(&(f.shape)(), &format!("m.get(\"{}\")", f.name), 0))
        .collect();
    let _ = writeln!(
        out,
        "{pad}    public static {name} fromData(Object data) {{\n{pad}        java.util.Map<String, Object> m = YogData.map(data);\n{pad}        return new {name}({});\n{pad}    }}\n",
        args.join(", ")
    );
    let _ = write!(
        out,
        "{pad}    public static {name} fromBytes(byte[] bytes) {{\n{pad}        return fromData(YogData.decode(bytes));\n{pad}    }}"
    );
    out.push_str(&frames::codec(name, s, pad));
    out.push_str(&views::view(name, s, pad));
    let body = body.replace('\n', &format!("\n{pad}"));
    let _ = writeln!(out, "{body}\n{pad}}}");
}

/// `address` in Java: its name in the hosts' `YogNames` (one source), or the
/// string itself if no name has it.
fn name(address: &str) -> String {
    yog_ids::export::java_ref(yog_ids::export::JAVA_CLASS, address)
        .unwrap_or_else(|| format!("{address:?}"))
}

/// Where a message goes: its own address (`Address.<constant>`, resolved
/// once), or its branch for the key field's value.
fn target(address: &str, key: Option<&str>, constant: &str) -> String {
    match key {
        Some(k) => format!("YogBus.keyed({}, {})", name(address), field(k)),
        None => format!("Address.{constant}"),
    }
}

/// A message to write: its place in the tree, its name, payload and methods.
struct Entry {
    path: &'static str,
    name: &'static str,
    shape: Shape,
    body: String,
}

/// Closes the classes of `open` down to `keep`.
fn close(out: &mut String, open: &mut Vec<String>, keep: usize) {
    while open.len() > keep {
        open.pop();
        let _ = writeln!(out, "{}}}", INDENT.repeat(open.len() + 1));
    }
}

/// Class `class` (documented `doc`) in `package`: `entries` nested by their paths.
fn file(
    package: &str,
    class: &str,
    doc: &str,
    mut o: Out,
    entries: &[Entry],
) -> (String, Vec<StructShape>) {
    let _ = writeln!(
        o.text,
        "\n/** {doc} */\npublic final class {class} {{\n    private {class}() {{}}"
    );
    let members = std::mem::take(&mut o.members);
    o.text.push_str(&members);
    // The class path open now (`Player`, `Session`), closed and opened as the paths go.
    let mut open: Vec<String> = Vec::new();
    let mut opened: Vec<String> = Vec::new();
    for e in entries {
        let path: Vec<&str> = e.path.split('/').collect();
        let common = open.iter().zip(&path).take_while(|(a, b)| a == *b).count();
        close(&mut o.text, &mut open, common);
        for (depth, seg) in path.iter().enumerate().skip(common) {
            // A class opened again would be a second class of one name.
            let whole = path[..=depth].join("/");
            assert!(
                !opened.contains(&whole),
                "the entries of `{whole}` aren't together"
            );
            opened.push(whole);
            let pad = INDENT.repeat(open.len() + 1);
            let class = class_name(seg);
            let _ = writeln!(
                o.text,
                "\n{pad}/** `{seg}`. */\n{pad}public static final class {class} {{\n{pad}    private {class}() {{}}"
            );
            open.push((*seg).to_owned());
        }
        let Shape::Struct(s) = &e.shape else { continue };
        // Java refuses a member type named like a class around it.
        assert!(
            open.iter().all(|seg| class_name(seg) != e.name),
            "{} is named like the class around it ({}): rename it",
            e.name,
            e.path
        );
        record(&mut o, &INDENT.repeat(open.len() + 1), e.name, s, &e.body);
    }
    close(&mut o.text, &mut open, 0);
    finish(package, o)
}

/// `class` holding `records` — and the records they hold in turn.
fn types_file(package: &str, records: Vec<StructShape>) -> String {
    let mut o = Out {
        records,
        ..Out::default()
    };
    let _ = writeln!(
        o.text,
        "\n/** The records the bus's messages share. */\npublic final class {TYPES} {{\n    private {TYPES}() {{}}"
    );
    let mut done = 0;
    while done < o.records.len() {
        let s = o.records[done].clone();
        record(&mut o, INDENT, s.name, &s, "");
        done += 1;
    }
    unique(&o.records);
    o.text.push_str(&words::classes(&o.records, INDENT));
    finish(package, o).0
}

/// The class closed, with its header and imports: its text, and the records it needs.
fn finish(package: &str, mut o: Out) -> (String, Vec<StructShape>) {
    o.text.push_str("}\n");
    let out = o.text;
    let names = format!(
        "{}.{}",
        yog_ids::export::JAVA_PACKAGE,
        yog_ids::export::JAVA_CLASS
    );
    let mut imports: Vec<String> = IMPORTS
        .iter()
        .filter(|(used, _)| out.contains(used))
        .map(|(_, class)| (*class).to_owned())
        .collect();
    imports.push(names);
    imports.sort();
    let imports: String = imports
        .iter()
        .map(|class| format!("import {class};\n"))
        .collect();
    let text = format!("// {HEADER}\npackage {package};\n\n{imports}{out}");
    (
        text.lines()
            .map(str::trim_end)
            .collect::<Vec<_>>()
            .join("\n")
            + "\n",
        o.records,
    )
}

/// Panics when two different payload structs share a record name — they'd
/// be one class in `YogTypes`.
fn unique(records: &[StructShape]) {
    for (i, a) in records.iter().enumerate() {
        for b in &records[i + 1..] {
            assert!(
                a.name != b.name || same(a, b),
                "two payload structs are named {}: rename one",
                a.name
            );
        }
    }
}

/// Payloads that travel inside other bytes (entity data), not as messages:
/// Java builds them too.
const PAYLOADS: &[fn() -> Shape] = &[
    <crate::entity::Hanging as yog_data::Schema>::shape,
    // The content the runtime hands the hosts at registration (`content_blob`).
    <crate::content::Content as yog_data::Schema>::shape,
];

/// Every generated class: its name and text.
pub fn files(package: &str) -> [(&'static str, String); 3] {
    let (events, mut records) = events::events(package);
    let (services, more) = services::services(package);
    for r in more {
        if !records.iter().any(|have| same(have, &r)) {
            records.push(r);
        }
    }
    for payload in PAYLOADS {
        let Shape::Struct(r) = payload() else {
            continue;
        };
        if !records.iter().any(|have| same(have, &r)) {
            records.push(r);
        }
    }
    unique(&records);
    [
        (events::CLASS, events),
        (services::CLASS, services),
        (TYPES, types_file(package, records)),
    ]
}

#[cfg(test)]
mod tests {
    use yog_data::{Schema, Shape};

    use super::unique;

    mod a {
        #[derive(yog_data::Schema)]
        pub struct Declare {
            pub name: String,
        }
    }

    mod b {
        #[derive(yog_data::Schema)]
        pub struct Declare {
            pub label: String,
        }
    }

    fn shape<T: Schema>() -> yog_data::schema::StructShape {
        let Shape::Struct(s) = T::shape() else {
            unreachable!("a derived struct")
        };
        s
    }

    #[test]
    #[should_panic(expected = "two payload structs are named Declare")]
    fn two_structs_of_one_name_are_refused() {
        unique(&[shape::<a::Declare>(), shape::<b::Declare>()]);
    }

    #[test]
    fn one_struct_seen_twice_is_fine() {
        unique(&[shape::<a::Declare>(), shape::<a::Declare>()]);
    }
}