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}