Skip to main content

sora_schema/
model.rs

1use std::{collections::BTreeMap, fmt};
2
3use serde::{
4    Deserialize, Deserializer,
5    de::{SeqAccess, Visitor},
6};
7
8#[derive(Debug, Clone, PartialEq, Deserialize)]
9pub struct SchemaFile {
10    pub package: String,
11
12    #[serde(default)]
13    pub codegen: CodegenSchema,
14
15    #[serde(default)]
16    pub localization: Option<LocalizationSchema>,
17
18    #[serde(default)]
19    pub includes: Vec<String>,
20
21    #[serde(default)]
22    pub enums: Vec<EnumSchema>,
23
24    #[serde(default)]
25    pub structs: Vec<StructSchema>,
26
27    #[serde(default)]
28    pub unions: Vec<UnionSchema>,
29
30    #[serde(default)]
31    pub tables: Vec<TableSchema>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35pub struct LocalizationSchema {
36    #[serde(default)]
37    pub locales: Vec<String>,
38    pub default_locale: Option<String>,
39    pub fallback_locale: Option<String>,
40    #[serde(default)]
41    pub sources: Vec<LocalizationSourceSchema>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45pub struct LocalizationSourceSchema {
46    pub name: String,
47    pub file: String,
48    pub sheet: Option<String>,
49    pub format: Option<String>,
50    #[serde(default = "default_localization_key")]
51    pub key: String,
52}
53
54fn default_localization_key() -> String {
55    "key".to_owned()
56}
57
58#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
59pub struct CodegenSchema {
60    #[serde(flatten)]
61    pub targets: BTreeMap<String, serde_json::Value>,
62}
63
64impl CodegenSchema {
65    pub fn target_options(&self, target: &str) -> Option<&serde_json::Value> {
66        self.targets.get(target)
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
71pub struct EnumSchema {
72    pub name: String,
73
74    #[serde(default)]
75    pub scope: ScopeSchema,
76
77    #[serde(default)]
78    pub values: Vec<String>,
79
80    #[serde(default)]
81    pub aliases: Vec<EnumAliasSchema>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
85pub struct EnumAliasSchema {
86    pub name: String,
87
88    pub alias: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
92pub struct StructSchema {
93    pub name: String,
94
95    #[serde(default)]
96    pub scope: ScopeSchema,
97
98    #[serde(default)]
99    pub fields: Vec<FieldSchema>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
103pub struct UnionSchema {
104    pub name: String,
105
106    #[serde(default)]
107    pub scope: ScopeSchema,
108
109    #[serde(default = "default_union_tag")]
110    pub tag: String,
111
112    #[serde(default)]
113    pub variants: Vec<UnionVariantSchema>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
117pub struct UnionVariantSchema {
118    pub name: String,
119
120    #[serde(default)]
121    pub scope: ScopeSchema,
122
123    #[serde(default)]
124    pub fields: Vec<FieldSchema>,
125}
126
127fn default_union_tag() -> String {
128    "type".to_owned()
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
132pub struct TableSchema {
133    pub name: String,
134    #[serde(default)]
135    pub scope: ScopeSchema,
136    pub mode: TableModeSchema,
137    pub key: Option<String>,
138    pub source: Option<TableSourceSchema>,
139
140    #[serde(default)]
141    pub fields: Vec<TableFieldSchema>,
142
143    #[serde(default)]
144    pub indexes: Vec<IndexSchema>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
148pub struct TableSourceSchema {
149    pub format: Option<String>,
150    pub file: String,
151    pub sheet: Option<String>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum TableModeSchema {
157    List,
158    Map,
159    Singleton,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
163pub struct IndexSchema {
164    pub name: String,
165
166    #[serde(default)]
167    pub fields: Vec<String>,
168
169    #[serde(default)]
170    pub unique: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct FieldSchema {
176    pub name: String,
177
178    #[serde(rename = "type")]
179    pub ty: String,
180
181    #[serde(default)]
182    pub scope: ScopeSchema,
183
184    pub comment: Option<String>,
185    pub default: Option<String>,
186    pub range: Option<[i64; 2]>,
187    pub length: Option<[usize; 2]>,
188    pub parser: Option<ParserSchema>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct TableFieldSchema {
194    pub name: String,
195
196    #[serde(rename = "type")]
197    pub ty: String,
198
199    #[serde(default)]
200    pub scope: ScopeSchema,
201
202    pub comment: Option<String>,
203    pub default: Option<String>,
204    pub range: Option<[i64; 2]>,
205    pub length: Option<[usize; 2]>,
206    pub parser: Option<ParserSchema>,
207    pub from: Option<TableFieldFromSchema>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct TableFieldFromSchema {
213    pub table: String,
214    pub parent_key: Option<String>,
215    pub child_key: Option<String>,
216    #[serde(rename = "field")]
217    pub value_field: Option<String>,
218    pub order_by: Option<String>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
222pub struct ParserSchema {
223    pub kind: String,
224
225    #[serde(flatten)]
226    pub options: BTreeMap<String, String>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ScopeSchema {
231    pub values: Vec<String>,
232}
233
234impl Default for ScopeSchema {
235    fn default() -> Self {
236        Self {
237            values: vec!["all".to_owned()],
238        }
239    }
240}
241
242impl<'de> Deserialize<'de> for ScopeSchema {
243    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
244    where
245        D: Deserializer<'de>,
246    {
247        struct ScopeVisitor;
248
249        impl<'de> Visitor<'de> for ScopeVisitor {
250            type Value = ScopeSchema;
251
252            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253                formatter.write_str("a scope string or list of scope strings")
254            }
255
256            fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
257            where
258                E: serde::de::Error,
259            {
260                Ok(ScopeSchema {
261                    values: vec![value.to_owned()],
262                })
263            }
264
265            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
266            where
267                A: SeqAccess<'de>,
268            {
269                let mut values = Vec::new();
270                while let Some(value) = seq.next_element::<String>()? {
271                    values.push(value);
272                }
273                Ok(ScopeSchema { values })
274            }
275        }
276
277        deserializer.deserialize_any(ScopeVisitor)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn loads_toml_schema() {
287        let schema: SchemaFile = toml::from_str(
288            r#"
289package = "game_config"
290
291[[enums]]
292name = "ItemType"
293values = ["Weapon", "Armor"]
294
295[[tables]]
296name = "Item"
297mode = "map"
298key = "id"
299
300[tables.source]
301file = "items.toml"
302
303[[tables.fields]]
304name = "id"
305type = "i32"
306comment = "Item id"
307
308[[tables.fields]]
309name = "tags"
310type = "list<string>"
311parser = { kind = "split", separator = "|" }
312"#,
313        )
314        .expect("schema should parse");
315
316        assert_eq!(schema.package, "game_config");
317        assert!(schema.codegen.targets.is_empty());
318        assert!(schema.includes.is_empty());
319        assert_eq!(schema.enums[0].name, "ItemType");
320        assert_eq!(schema.tables[0].mode, TableModeSchema::Map);
321        assert_eq!(schema.tables[0].source.as_ref().unwrap().format, None);
322        assert_eq!(schema.tables[0].fields[0].name, "id");
323        let parser = schema.tables[0].fields[1].parser.as_ref().unwrap();
324        assert_eq!(parser.kind, "split");
325        assert_eq!(parser.options["separator"], "|");
326    }
327
328    #[test]
329    fn defaults_optional_collections_and_field_flags() {
330        let schema: SchemaFile = toml::from_str(
331            r#"
332package = "game_config"
333includes = ["items.toml"]
334
335[[tables]]
336name = "Item"
337mode = "list"
338
339[[tables.fields]]
340name = "name"
341type = "string"
342"#,
343        )
344        .expect("schema should parse");
345
346        assert!(schema.enums.is_empty());
347        assert_eq!(schema.includes, ["items.toml"]);
348        assert!(schema.structs.is_empty());
349        assert!(schema.tables[0].indexes.is_empty());
350    }
351
352    #[test]
353    fn rejects_table_only_properties_on_struct_fields() {
354        let error = toml::from_str::<SchemaFile>(
355            r#"
356package = "game_config"
357
358[[structs]]
359name = "Reward"
360
361[[structs.fields]]
362name = "item_id"
363type = "i32"
364from = { table = "RewardRow", parent_key = "id", child_key = "reward_id" }
365"#,
366        )
367        .unwrap_err();
368
369        assert!(error.to_string().contains("unknown field `from`"));
370    }
371
372    #[test]
373    fn loads_codegen_options() {
374        let schema: SchemaFile = toml::from_str(
375            r#"
376package = "game_config"
377
378[codegen.rust]
379runtime_format = "sora"
380map_type = "fx_hash_map"
381string_storage = "arc"
382
383[codegen.kotlin]
384runtime_format = "sora"
385
386[codegen.godot]
387runtime_format = "json"
388
389[codegen.c]
390runtime_format = "sora"
391c_standard = "c17"
392prefix = "game_config"
393
394[codegen.cpp]
395runtime_format = "sora"
396cpp_standard = "c++20"
397namespace = "sora::game_config"
398
399[codegen.typescript]
400runtime_format = "sora"
401enum_repr = "string"
402
403[codegen.javascript]
404runtime_format = "sora"
405enum_repr = "integer"
406emit_dts = false
407
408[codegen.erlang]
409runtime_format = "sora"
410enum_repr = "atom"
411
412[codegen.lua]
413runtime_format = "sora"
414module = "generated.lua"
415lua_version = "5.4"
416enum_repr = "string"
417"#,
418        )
419        .expect("schema should parse");
420
421        assert_eq!(
422            schema.codegen.targets["rust"]["map_type"],
423            serde_json::Value::String("fx_hash_map".to_owned())
424        );
425        assert_eq!(
426            schema.codegen.targets["rust"]["string_storage"],
427            serde_json::Value::String("arc".to_owned())
428        );
429        assert_eq!(
430            schema.codegen.targets["godot"]["runtime_format"],
431            serde_json::Value::String("json".to_owned())
432        );
433        assert_eq!(
434            schema.codegen.targets["cpp"]["namespace"],
435            serde_json::Value::String("sora::game_config".to_owned())
436        );
437        assert_eq!(
438            schema.codegen.targets["javascript"]["emit_dts"],
439            serde_json::Value::Bool(false)
440        );
441    }
442}