Skip to main content

simploxide_bindgen/types/
mod.rs

1//! Defines both data types that represent different typekinds in the SimpleX API docs as well as
2//! the parser that turns TYPES.md into an iterator which yields [`crate::types::ApiType`].
3//!
4//! The `std::fmt::Display` implementations render types as the Rust code tailored for
5//! `simploxide-api-types` crate, but you can easily override them with a newtype like
6//! `CustomFmt<'a>(&'a ApiType);`.
7
8pub mod discriminated_union_type;
9pub mod enum_type;
10pub mod record_type;
11
12pub use discriminated_union_type::{
13    DiscriminatedUnionType, DisjointedDiscriminatedUnion, DisjointedDisriminatedUnionVariant,
14};
15pub use enum_type::EnumType;
16pub use record_type::RecordType;
17
18use convert_case::{Case, Casing as _};
19use std::str::FromStr;
20
21use crate::parse_utils;
22
23pub fn parse(types_md: &str) -> impl Iterator<Item = Result<ApiType, String>> {
24    types_md.split("---").skip(1).map(ApiType::from_str)
25}
26
27pub enum ApiType {
28    Record(RecordType),
29    DiscriminatedUnion(DiscriminatedUnionType),
30    Enum(EnumType),
31}
32
33impl ApiType {
34    /// True if type represents an error type.
35    pub fn is_error(&self) -> bool {
36        self.name().contains("Error")
37    }
38
39    pub fn name(&self) -> &str {
40        match self {
41            Self::Record(r) => r.name.as_str(),
42            Self::Enum(e) => e.name.as_str(),
43            Self::DiscriminatedUnion(du) => du.name.as_str(),
44        }
45    }
46}
47
48impl std::str::FromStr for ApiType {
49    type Err = String;
50
51    fn from_str(md_block: &str) -> Result<Self, Self::Err> {
52        fn parser<'a>(mut lines: impl Iterator<Item = &'a str>) -> Result<ApiType, String> {
53            const TYPENAME_PAT: &str = parse_utils::H2;
54            const TYPEKIND_PAT: &str = parse_utils::BOLD;
55
56            let typename = parse_utils::skip_empty(&mut lines)
57                .and_then(|s| s.strip_prefix(TYPENAME_PAT))
58                .ok_or_else(|| format!("Failed to find a type name by pattern {TYPENAME_PAT:?}"))?;
59
60            let mut doc_comments = Vec::new();
61
62            let typekind = parse_utils::parse_doc_lines(&mut lines, &mut doc_comments, |s| {
63                s.starts_with(TYPEKIND_PAT)
64            })
65            .map(|s| s.strip_prefix(TYPEKIND_PAT).unwrap())
66            .ok_or_else(|| format!("Failed to find a type kind by pattern {TYPEKIND_PAT:?}"))?;
67
68            let mut syntax = String::new();
69            let breaker = |s: &str| s.starts_with("**Syntax");
70
71            if typekind.starts_with("Record") {
72                let mut fields = Vec::new();
73
74                let syntax_block =
75                    parse_utils::parse_record_fields(&mut lines, &mut fields, breaker)?;
76
77                if syntax_block.is_some() {
78                    parse_utils::parse_syntax(&mut lines, &mut syntax)?;
79                }
80
81                Ok(ApiType::Record(RecordType {
82                    name: typename.to_owned(),
83                    fields,
84                    doc_comments,
85                    syntax,
86                }))
87            } else if typekind.starts_with("Enum") {
88                let mut variants = Vec::new();
89
90                let syntax_block =
91                    parse_utils::parse_enum_variants(&mut lines, &mut variants, breaker)?;
92
93                if syntax_block.is_some() {
94                    parse_utils::parse_syntax(&mut lines, &mut syntax)?;
95                }
96
97                Ok(ApiType::Enum(EnumType {
98                    name: typename.to_owned(),
99                    variants,
100                    doc_comments,
101                    syntax,
102                }))
103            } else if typekind.starts_with("Discriminated") {
104                let mut variants = Vec::new();
105
106                let syntax_block = parse_utils::parse_discriminated_union_variants(
107                    &mut lines,
108                    &mut variants,
109                    breaker,
110                )?;
111
112                if syntax_block.is_some() {
113                    parse_utils::parse_syntax(&mut lines, &mut syntax)?;
114                }
115
116                Ok(ApiType::DiscriminatedUnion(DiscriminatedUnionType {
117                    name: typename.to_owned(),
118                    variants,
119                    doc_comments,
120                    syntax,
121                }))
122            } else {
123                Err(format!("Unknown type kind: {typekind:?}"))
124            }
125        }
126
127        parser(md_block.lines().map(str::trim))
128            .map_err(|e| format!("{e} in md block\n```\n{md_block}\n```"))
129    }
130}
131
132impl std::fmt::Display for ApiType {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::Record(r) => r.fmt(f),
136            Self::Enum(e) => e.fmt(f),
137            Self::DiscriminatedUnion(du) => du.fmt(f),
138        }
139    }
140}
141
142/// The source file a compound field type is defined in.
143#[derive(Debug, Clone, PartialEq)]
144pub enum Source {
145    Types,
146    Commands,
147    Events,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub struct Field {
152    pub api_name: String,
153    pub rust_name: String,
154    pub typ: String,
155    /// The source file of the compound type, parsed from the markdown link.
156    /// `None` for primitive types and same-file anchor links (`#anchor`).
157    pub source: Option<Source>,
158}
159
160impl Field {
161    pub fn from_api_name(api_name: String, typ: String) -> Self {
162        Self {
163            api_name: api_name.clone(),
164            rust_name: api_name.remove_empty().to_case(Case::Snake),
165            typ,
166            source: None,
167        }
168    }
169    pub fn is_optional(&self) -> bool {
170        is_optional_type(self.typ.as_str())
171    }
172
173    pub fn is_vec(&self) -> bool {
174        is_vec_type(self.typ.as_str())
175    }
176
177    pub fn is_map(&self) -> bool {
178        is_map_type(self.typ.as_str())
179    }
180
181    pub fn is_numeric(&self) -> bool {
182        is_numeric_type(self.typ.as_str())
183    }
184
185    pub fn is_bool(&self) -> bool {
186        is_bool_type(self.typ.as_str())
187    }
188
189    pub fn is_string(&self) -> bool {
190        is_string_type(self.typ.as_str())
191    }
192
193    pub fn is_compound(&self) -> bool {
194        is_compound_type(self.typ.as_str())
195    }
196
197    /// The field represents some error type
198    pub fn is_error(&self) -> bool {
199        self.typ.contains("Error")
200    }
201
202    /// Retrieves the inner type of Option<_> or Vec<_>
203    /// Retrieves the value type of `BTreeMap<Key, Value>`
204    /// Returns None if the field type is not Option, Vec or BTreeMap.
205    pub fn inner_type(&self) -> Option<&str> {
206        inner_type(self.typ.as_str())
207    }
208
209    /// Like [`inner_type`] but returns an offset to the inner type in cthe original type string
210    pub fn inner_type_offset(&self) -> Option<usize> {
211        inner_type_offset(self.typ.as_str())
212    }
213
214    /// Returns a base type(a type with all container types unwrapped)
215    /// E.g.
216    /// `Message -> Message`
217    /// `Option<Message> -> Message`
218    /// `BTreeMap<i64, Option<Vec<Message>>> -> Message`
219    pub fn base_type(&self) -> &str {
220        let mut ret = self.typ.as_str();
221
222        while let Some(inner) = inner_type(ret) {
223            ret = inner
224        }
225
226        ret
227    }
228
229    /// Like a [`base_type`] but returns an offset to the base type in the original type string
230    pub fn base_type_offset(&self) -> usize {
231        let mut ret = 0;
232
233        while let Some(offset) = inner_type_offset(&self.typ[ret..]) {
234            ret += offset;
235        }
236
237        ret
238    }
239}
240
241impl FromStr for Field {
242    type Err = String;
243
244    fn from_str(line: &str) -> Result<Self, Self::Err> {
245        let (name, typ) = line
246            .trim()
247            .split_once(':')
248            .ok_or_else(|| format!("Failed to parse field at line: '{line}'"))?;
249
250        let api_name = name.trim().to_owned();
251        let rust_name = api_name.remove_empty().to_case(Case::Snake);
252        let raw_typ = typ.trim();
253        let typ = resolve_type(raw_typ)?;
254        let source = parse_field_source(raw_typ);
255
256        Ok(Field {
257            api_name,
258            rust_name,
259            typ,
260            source,
261        })
262    }
263}
264
265pub fn is_optional_type(typ: &str) -> bool {
266    typ.starts_with("Option<")
267}
268
269pub fn is_vec_type(typ: &str) -> bool {
270    typ.starts_with("Vec<")
271}
272
273pub fn is_map_type(typ: &str) -> bool {
274    typ.starts_with("BTreeMap<")
275}
276
277pub fn is_numeric_type(typ: &str) -> bool {
278    typ.starts_with("i")
279        || typ.starts_with("f")
280        || typ.starts_with("u")
281        || typ.starts_with("Option<i")
282        || typ.starts_with("Option<f")
283        || typ.starts_with("Option<u")
284}
285
286pub fn is_bool_type(typ: &str) -> bool {
287    typ == "bool"
288}
289
290pub fn is_string_type(typ: &str) -> bool {
291    typ == "String" || typ == "UtcTime"
292}
293
294pub fn is_compound_type(typ: &str) -> bool {
295    !is_optional_type(typ)
296        && !is_vec_type(typ)
297        && !is_map_type(typ)
298        && !is_numeric_type(typ)
299        && !is_bool_type(typ)
300        && !is_string_type(typ)
301}
302
303/// Retrieves the inner type of `Option<_>` or `Vec<_>`
304/// Retrieve the value type of `BTreeMap<Key, Value>`
305/// Returns None if the field type is not Option Vec or BTreeMap.
306pub fn inner_type(typ: &str) -> Option<&str> {
307    let start = inner_type_offset(typ)?;
308    let end = typ.rfind('>')?;
309    Some(&typ[start..end])
310}
311
312pub fn inner_type_offset(typ: &str) -> Option<usize> {
313    if typ.strip_prefix("Option<").is_some() {
314        Some("Option<".len())
315    } else if typ.strip_prefix("Vec<").is_some() {
316        Some("Vec<".len())
317    } else if let Some(s) = typ.strip_prefix("BTreeMap<") {
318        let mut total_offset = s.find(',').unwrap();
319        total_offset += s[total_offset..].find(char::is_alphabetic).unwrap();
320        Some("BTreeMap<".len() + total_offset)
321    } else {
322        None
323    }
324}
325
326/// Extracts the source file from a markdown link embedded in a raw type string.
327///
328/// Handles cross-file links like `[TypeName](./TYPES.md#typename)` and returns `None`
329/// for primitives, same-file anchor links (`[TypeName](#anchor)`), or unlinked types.
330fn parse_field_source(raw_typ: &str) -> Option<Source> {
331    const LINK_START: &str = "](";
332    let url_start = raw_typ.find(LINK_START)?;
333    let rest = &raw_typ[url_start + LINK_START.len()..];
334    let url_end = rest.find(')')?;
335    let url = &rest[..url_end];
336    if url.contains("TYPES.md") {
337        Some(Source::Types)
338    } else if url.contains("COMMANDS.md") {
339        Some(Source::Commands)
340    } else if url.contains("EVENTS.md") {
341        Some(Source::Events)
342    } else {
343        None
344    }
345}
346
347fn resolve_type(t: &str) -> Result<String, String> {
348    if let Some(t) = t.strip_suffix('}') {
349        let t = t.strip_prefix('{').unwrap().trim();
350        let (lhs, rhs) = t.split_once(':').unwrap();
351
352        let key = resolve_type(lhs.trim())?;
353        let val = resolve_type(rhs.trim())?;
354
355        return Ok(format!("BTreeMap<{key}, {val}>"));
356    }
357
358    if let Some(t) = t.strip_suffix(']') {
359        let resolved = resolve_type(t.strip_prefix('[').unwrap())?;
360        return Ok(format!("Vec<{resolved}>"));
361    }
362
363    if let Some(t) = t.strip_suffix('?') {
364        let resolved = resolve_type(t)?;
365        return Ok(format!("Option<{resolved}>"));
366    }
367
368    let resolved = match t {
369        "bool" => "bool".to_owned(),
370        "int" => "i32".to_owned(),
371        "int64" => "i64".to_owned(),
372        "word32" => "u32".to_owned(),
373        "double" => "f64".to_owned(),
374        "string" => "String".to_owned(),
375        // These types map into themselves to preserve semantics.
376        // The generated module MUST have the typedefs like these:
377        //  - type UtcTime = String;
378        //  - type JsonObject = serde_json::Value;
379        "UTCTime" => "UtcTime".to_owned(),
380        "JSONObject" => "JsonObject".to_owned(),
381
382        compound if compound.starts_with('[') => {
383            let end = compound.find(']').unwrap();
384            compound['['.len_utf8()..end].to_owned()
385        }
386
387        _ => return Err(format!("Failed to resolve type: `{t}`")),
388    };
389
390    Ok(resolved)
391}
392
393/// A helper that allows to configure how the field should be rendred.
394pub struct FieldFmt<'a> {
395    field: &'a Field,
396    offset: usize,
397    is_pub: bool,
398}
399
400impl<'a> FieldFmt<'a> {
401    pub fn new(field: &'a Field) -> Self {
402        Self::with_offset(field, 0)
403    }
404
405    pub fn with_offset(field: &'a Field, offset: usize) -> Self {
406        Self {
407            field,
408            offset,
409            is_pub: false,
410        }
411    }
412
413    pub fn set_pub(&mut self, new: bool) {
414        self.is_pub = new;
415    }
416}
417
418impl<'a> std::fmt::Display for FieldFmt<'a> {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        let offset = " ".repeat(self.offset);
421        let pub_ = if self.is_pub { "pub " } else { "" };
422        let is_numeric = self.field.is_numeric();
423        let is_bool = self.field.is_bool();
424        let is_optional = self.field.is_optional();
425
426        write!(f, "{offset}#[serde(rename = \"{}\"", self.field.api_name)?;
427
428        if is_optional {
429            write!(f, ", skip_serializing_if = \"Option::is_none\"")?;
430        }
431
432        if is_numeric {
433            if is_optional {
434                write!(
435                    f,
436                    ", deserialize_with=\"deserialize_option_number_from_string\", default"
437                )?;
438            } else {
439                write!(f, ", deserialize_with=\"deserialize_number_from_string\"")?;
440            }
441        } else if is_bool {
442            write!(f, ", default")?;
443        }
444
445        writeln!(f, ")]")?;
446        writeln!(
447            f,
448            "{offset}{pub_}{}: {},",
449            self.field.rust_name, self.field.typ
450        )?;
451
452        Ok(())
453    }
454}
455
456/// Converts markdown-style API doc links in a doc comment line to Rust intra-doc links. HTTP/HTTPS
457/// links are left unchanged.
458pub(crate) fn convert_doc_links(line: &str) -> std::borrow::Cow<'_, str> {
459    if !line.contains("](") {
460        return std::borrow::Cow::Borrowed(line);
461    }
462
463    let mut result = String::with_capacity(line.len());
464    let mut remaining = line;
465
466    while let Some(bracket_pos) = remaining.find('[') {
467        let after_open = &remaining[bracket_pos + '['.len_utf8()..];
468        if let Some(close_bracket) = after_open.find("](") {
469            let display = &after_open[..close_bracket];
470            let after_close = &after_open[close_bracket + "](".len()..];
471            if let Some(paren_close) = after_close.find(')') {
472                let url = &after_close[..paren_close];
473                if url.starts_with('#') || url.contains(".md") {
474                    result.push_str(&remaining[..bracket_pos]);
475                    result.push('[');
476                    result.push_str(&display.remove_empty().to_case(Case::Pascal));
477                    result.push(']');
478                    remaining = &after_close[paren_close + ')'.len_utf8()..];
479                    continue;
480                }
481            }
482        }
483        // Not a convertible link; advance past this `[`
484        result.push_str(&remaining[..bracket_pos + '['.len_utf8()]);
485        remaining = &remaining[bracket_pos + '['.len_utf8()..];
486    }
487
488    result.push_str(remaining);
489    std::borrow::Cow::Owned(result)
490}
491
492/// A common impl for outer docs rendering shared by all type kinds.
493pub(crate) trait TopLevelDocs {
494    fn doc_lines(&self) -> &Vec<String>;
495
496    fn syntax(&self) -> &str;
497
498    fn write_docs_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        for line in self.doc_lines() {
500            writeln!(f, "/// {}", convert_doc_links(line))?;
501        }
502
503        if !self.syntax().is_empty() {
504            if !self.doc_lines().is_empty() {
505                writeln!(f, "///")?;
506            }
507
508            writeln!(f, "/// *Syntax:*")?;
509            writeln!(f, "///")?;
510            writeln!(f, "/// ```")?;
511            writeln!(f, "/// {}", self.syntax())?;
512            writeln!(f, "/// ```")?;
513        }
514
515        Ok(())
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn inner_type_test() {
525        let option = "Option<String>";
526        let vec = "Vec<i64>";
527        let map = "BTreeMap<i64, Option<Vec<JsonObject>>>";
528        let regular = "String";
529
530        assert_eq!(inner_type(option), Some("String"));
531        assert_eq!(inner_type(vec), Some("i64"));
532        let map_value = inner_type(map).unwrap();
533        assert_eq!(map_value, "Option<Vec<JsonObject>>");
534        let inner_vec = inner_type(map_value).unwrap();
535        assert_eq!(inner_vec, "Vec<JsonObject>");
536        let json = inner_type(inner_vec).unwrap();
537        assert_eq!(json, "JsonObject");
538
539        assert_eq!(inner_type(json), None);
540        assert_eq!(inner_type(regular), None);
541
542        let mut option = String::from(option);
543        let offset = inner_type_offset(&option).unwrap();
544        option.insert_str(offset, "NotA");
545
546        assert_eq!(option, "Option<NotAString>");
547    }
548}