mod_serde/
lib.rs

1use anyhow::Context;
2use config::{
3    any_err, get_or_create_module, get_or_create_sub_module, materialize_to_lua_value,
4    serialize_options,
5};
6use mlua::{Lua, LuaSerdeExt, Value as LuaValue};
7use serde_json::Value as JValue;
8
9pub fn register(lua: &Lua) -> anyhow::Result<()> {
10    let serde_mod = get_or_create_sub_module(lua, "serde")?;
11    let kumo_mod = get_or_create_module(lua, "kumo")?;
12
13    serde_mod.set("json_load", lua.create_async_function(json_load)?)?;
14    serde_mod.set("json_parse", lua.create_function(json_parse)?)?;
15    serde_mod.set("json_encode", lua.create_function(json_encode)?)?;
16    serde_mod.set(
17        "json_encode_pretty",
18        lua.create_function(json_encode_pretty)?,
19    )?;
20
21    serde_mod.set("toml_load", lua.create_async_function(toml_load)?)?;
22    serde_mod.set("toml_parse", lua.create_function(toml_parse)?)?;
23    serde_mod.set("toml_encode", lua.create_function(toml_encode)?)?;
24    serde_mod.set(
25        "toml_encode_pretty",
26        lua.create_function(toml_encode_pretty)?,
27    )?;
28    serde_mod.set(
29        "toml_encode_pretty_compact",
30        lua.create_function(toml_encode_pretty_compact_lua)?,
31    )?;
32
33    serde_mod.set("yaml_load", lua.create_async_function(yaml_load)?)?;
34    serde_mod.set("yaml_parse", lua.create_function(yaml_parse)?)?;
35    serde_mod.set("yaml_encode", lua.create_function(yaml_encode)?)?;
36    // Note there is no pretty encoder for yaml, because the default one is pretty already.
37    // See https://github.com/dtolnay/serde-yaml/issues/226
38
39    // Backwards compatibility
40    kumo_mod.set("json_load", lua.create_async_function(json_load)?)?;
41    kumo_mod.set("json_parse", lua.create_function(json_parse)?)?;
42    kumo_mod.set("json_encode", lua.create_function(json_encode)?)?;
43    kumo_mod.set(
44        "json_encode_pretty",
45        lua.create_function(json_encode_pretty)?,
46    )?;
47
48    kumo_mod.set("toml_load", lua.create_async_function(toml_load)?)?;
49    kumo_mod.set("toml_parse", lua.create_function(toml_parse)?)?;
50    kumo_mod.set("toml_encode", lua.create_function(toml_encode)?)?;
51    kumo_mod.set(
52        "toml_encode_pretty",
53        lua.create_function(toml_encode_pretty)?,
54    )?;
55
56    Ok(())
57}
58
59async fn json_load(lua: Lua, file_name: String) -> mlua::Result<LuaValue> {
60    let data = tokio::fs::read(&file_name)
61        .await
62        .with_context(|| format!("reading file {file_name}"))
63        .map_err(any_err)?;
64
65    let stripped = json_comments::StripComments::new(&*data);
66
67    let obj: serde_json::Value = serde_json::from_reader(stripped)
68        .with_context(|| format!("parsing {file_name} as json"))
69        .map_err(any_err)?;
70    lua.to_value_with(&obj, serialize_options())
71}
72
73fn json_parse(lua: &Lua, text: String) -> mlua::Result<LuaValue> {
74    let stripped = json_comments::StripComments::new(text.as_bytes());
75    let obj: serde_json::Value = serde_json::from_reader(stripped)
76        .with_context(|| format!("parsing {text} as json"))
77        .map_err(any_err)?;
78    lua.to_value_with(&obj, serialize_options())
79}
80
81fn json_encode(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
82    let value = materialize_to_lua_value(lua, value)?;
83    serde_json::to_string(&value).map_err(any_err)
84}
85
86fn json_encode_pretty(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
87    let value = materialize_to_lua_value(lua, value)?;
88    // This additional conversion to a json value causes any object types
89    // to become backed by a BTreeMap rather than lua's randomly ordered
90    // table type, so that the pretty print will show the keys in sorted order.
91    // We only do that for pretty printing because we don't usually want
92    // to bother with that additional overhead
93    let value = serde_json::to_value(&value).map_err(any_err)?;
94    serde_json::to_string_pretty(&value).map_err(any_err)
95}
96
97async fn toml_load(lua: Lua, file_name: String) -> mlua::Result<LuaValue> {
98    let data = tokio::fs::read_to_string(&file_name)
99        .await
100        .with_context(|| format!("reading file {file_name}"))
101        .map_err(any_err)?;
102
103    let obj: toml::Value = toml::from_str(&data)
104        .with_context(|| format!("parsing {file_name} as toml"))
105        .map_err(any_err)?;
106    lua.to_value_with(&obj, serialize_options())
107}
108
109fn toml_parse(lua: &Lua, toml: String) -> mlua::Result<LuaValue> {
110    let obj: toml::Value = toml::from_str(&toml)
111        .with_context(|| format!("parsing {toml} as toml"))
112        .map_err(any_err)?;
113    lua.to_value_with(&obj, serialize_options())
114}
115
116fn toml_encode(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
117    let value = materialize_to_lua_value(lua, value)?;
118    toml::to_string(&value).map_err(any_err)
119}
120
121fn toml_encode_pretty(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
122    let value = materialize_to_lua_value(lua, value)?;
123    toml::to_string_pretty(&value).map_err(any_err)
124}
125
126/// Render a value as pretty TOML with:
127///
128/// - Keys sorted alphabetically at every nesting level.
129/// - Empty tables emitted inline as `{}` rather than as a `[name]`
130///   header. Empty arrays already render inline as `[]` via the
131///   default toml serializer, so no special handling is needed.
132///
133/// Useful for human-readable diagnostic output where stable
134/// scan-friendly ordering matters and empty-table headers would
135/// add visual noise.
136pub fn toml_encode_pretty_compact<T: serde::Serialize>(value: &T) -> anyhow::Result<String> {
137    let initial = toml::to_string(value).context("serializing value as TOML")?;
138    let mut doc: toml_edit::DocumentMut = initial
139        .parse()
140        .context("re-parsing TOML for format normalization")?;
141    fn normalize(table: &mut toml_edit::Table) {
142        // Collect keys of empty sub-tables and re-insert them as
143        // inline empty tables via `insert()`, which establishes the
144        // standard `key = {}` decor. Mutating the Item in place
145        // would leave the original table-header decor on the key
146        // (no `=` separator).
147        let empty_keys: Vec<String> = table
148            .iter()
149            .filter_map(|(k, v)| match v {
150                toml_edit::Item::Table(t) if t.is_empty() => Some(k.to_string()),
151                _ => None,
152            })
153            .collect();
154        for key in empty_keys {
155            table.insert(
156                &key,
157                toml_edit::Item::Value(
158                    toml_edit::Value::InlineTable(toml_edit::InlineTable::new()),
159                ),
160            );
161        }
162
163        // Recurse before sorting; sub-tables that remain (non-empty)
164        // get their own inner sort.
165        for (_, item) in table.iter_mut() {
166            match item {
167                toml_edit::Item::Table(t) => normalize(t),
168                toml_edit::Item::ArrayOfTables(arr) => {
169                    for t in arr.iter_mut() {
170                        normalize(t);
171                    }
172                }
173                _ => {}
174            }
175        }
176
177        // Sort the value entries alphabetically. TOML grammar
178        // requires sub-tables to follow value entries, so sub-tables
179        // naturally trail the sorted values at this level.
180        table.sort_values();
181    }
182    normalize(doc.as_table_mut());
183    Ok(doc.to_string())
184}
185
186fn toml_encode_pretty_compact_lua(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
187    let value = materialize_to_lua_value(lua, value)?;
188    toml_encode_pretty_compact(&value).map_err(any_err)
189}
190
191async fn yaml_load(lua: Lua, file_name: String) -> mlua::Result<LuaValue> {
192    let data = tokio::fs::read(&file_name)
193        .await
194        .with_context(|| format!("reading file {file_name}"))
195        .map_err(any_err)?;
196
197    let value: JValue = serde_yaml::from_slice(&data)
198        .with_context(|| format!("parsing {file_name} as yaml"))
199        .map_err(any_err)?;
200    lua.to_value_with(&value, serialize_options())
201}
202
203fn yaml_parse(lua: &Lua, text: String) -> mlua::Result<LuaValue> {
204    let value: JValue = serde_yaml::from_str(&text)
205        .with_context(|| format!("parsing {text} as yaml"))
206        .map_err(any_err)?;
207    lua.to_value_with(&value, serialize_options())
208}
209
210fn yaml_encode(lua: &Lua, value: LuaValue) -> mlua::Result<String> {
211    let value = materialize_to_lua_value(lua, value)?;
212    serde_yaml::to_string(&value).map_err(any_err)
213}