Skip to main content

typify_macro/
lib.rs

1// Copyright 2025 Oxide Computer Company
2
3//! typify macro implementation.
4
5#![deny(missing_docs)]
6
7use std::{collections::HashMap, path::Path};
8
9use proc_macro::TokenStream;
10use quote::{quote, ToTokens};
11use serde::Deserialize;
12use serde_tokenstream::{ParseWrapper, TokenStreamWrapper};
13use syn::LitStr;
14use token_utils::TypeAndImpls;
15use typify_impl::{
16    CrateVers, MapType, TypeSpace, TypeSpacePatch, TypeSpaceSettings, UnknownPolicy,
17};
18
19mod token_utils;
20
21/// Import types from a schema file. This may be invoked with simply a pathname
22/// for a JSON Schema file (relative to `$CARGO_MANIFEST_DIR`), or it may be
23/// invoked with a structured form:
24/// ```
25/// use typify_macro::import_types;
26/// import_types!(
27///     schema = "../example.json",
28///     derives = [schemars::JsonSchema],
29/// );
30/// ```
31///
32/// - `schema`: string literal; the JSON schema file
33///
34/// - `derives`: optional array of derive macro paths; the derive macros to be
35///   applied to all generated types
36///
37/// - `attrs`: optional array of attribute paths; the attributes to be applied
38///   to all generated types
39///
40/// - `struct_builder`: optional boolean; (if true) generates a `::builder()`
41///   method for each generated struct that can be used to specify each
42///   property and construct the struct
43///
44/// - `unknown_crates`: optional policy regarding the handling of schemas that
45///   contain the `x-rust-type` extension whose crates are not explicitly named
46///   in the `crates` section. The options are `Generate` to ignore the
47///   extension and generate a *de novo* type, `Allow` to use the named type
48///   (which may require the addition of a new dependency to compile, and which
49///   ignores version compatibility checks), or `Deny` to produce a
50///   compile-time error (requiring the user to specify the crate's disposition
51///   in the `crates` section).
52///
53/// - `crates`: optional map from crate name to the version of the crate in
54///   use. Types encountered with the Rust type extension (`x-rust-type`) will
55///   use types from the specified crates rather than generating them (within
56///   the constraints of type compatibility).
57///
58/// - `patch`: optional map from type to an object with the optional members
59///   `rename` and `derives`. This may be used to renamed generated types or
60///   to apply additional (non-default) derive macros to them.
61///
62/// - `replace`: optional map from definition name to a replacement type. This
63///   may be used to skip generation of the named type and use a existing Rust
64///   type.
65///
66/// - `convert`: optional map from a JSON schema type defined in `$defs` to a
67///   replacement type. This may be used to skip generation of the schema and
68///   use an existing Rust type.
69#[proc_macro]
70pub fn import_types(item: TokenStream) -> TokenStream {
71    match do_import_types(item) {
72        Err(err) => err.to_compile_error().into(),
73        Ok(out) => out,
74    }
75}
76
77#[derive(Deserialize)]
78struct MacroSettings {
79    schema: ParseWrapper<LitStr>,
80    #[serde(default)]
81    derives: Vec<ParseWrapper<syn::Path>>,
82    #[serde(default)]
83    attrs: Vec<TokenStreamWrapper>,
84    #[serde(default)]
85    struct_builder: bool,
86
87    #[serde(default)]
88    unknown_crates: UnknownPolicy,
89    #[serde(default)]
90    crates: HashMap<CrateName, MacroCrateSpec>,
91    #[serde(default)]
92    map_type: Option<ParseWrapper<syn::Type>>,
93
94    #[serde(default)]
95    patch: HashMap<ParseWrapper<syn::Ident>, MacroPatch>,
96    #[serde(default)]
97    replace: HashMap<ParseWrapper<syn::Ident>, ParseWrapper<TypeAndImpls>>,
98    #[serde(default)]
99    convert:
100        serde_tokenstream::OrderedMap<schemars::schema::SchemaObject, ParseWrapper<TypeAndImpls>>,
101}
102
103struct MacroCrateSpec {
104    original: Option<String>,
105    version: CrateVers,
106}
107
108impl<'de> Deserialize<'de> for MacroCrateSpec {
109    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110    where
111        D: serde::Deserializer<'de>,
112    {
113        let ss = String::deserialize(deserializer)?;
114
115        let (original, vers_str) = if let Some(ii) = ss.find('@') {
116            let original_str = &ss[..ii];
117            let rest = &ss[ii + 1..];
118            if !is_crate(original_str) {
119                return Err(<D::Error as serde::de::Error>::invalid_value(
120                    serde::de::Unexpected::Str(&ss),
121                    &"valid crate name",
122                ));
123            }
124
125            (Some(original_str.to_string()), rest)
126        } else {
127            (None, ss.as_ref())
128        };
129
130        let Some(version) = CrateVers::parse(vers_str) else {
131            return Err(<D::Error as serde::de::Error>::invalid_value(
132                serde::de::Unexpected::Str(&ss),
133                &"valid version",
134            ));
135        };
136
137        Ok(Self { original, version })
138    }
139}
140
141#[derive(Hash, PartialEq, Eq)]
142struct CrateName(String);
143impl<'de> Deserialize<'de> for CrateName {
144    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
145    where
146        D: serde::Deserializer<'de>,
147    {
148        let ss = String::deserialize(deserializer)?;
149
150        if is_crate(&ss) {
151            Ok(Self(ss))
152        } else {
153            Err(<D::Error as serde::de::Error>::invalid_value(
154                serde::de::Unexpected::Str(&ss),
155                &"valid crate name",
156            ))
157        }
158    }
159}
160
161fn is_crate(s: &str) -> bool {
162    !s.contains(|cc: char| !cc.is_alphanumeric() && cc != '_' && cc != '-')
163}
164
165#[derive(Deserialize)]
166struct MacroPatch {
167    #[serde(default)]
168    rename: Option<String>,
169    #[serde(default)]
170    derives: Vec<ParseWrapper<syn::Path>>,
171    #[serde(default)]
172    attrs: Vec<TokenStreamWrapper>,
173}
174
175impl From<MacroPatch> for TypeSpacePatch {
176    fn from(a: MacroPatch) -> Self {
177        let mut s = Self::default();
178        a.rename.iter().for_each(|rename| {
179            s.with_rename(rename);
180        });
181        a.derives.iter().for_each(|derive| {
182            s.with_derive(derive.to_token_stream());
183        });
184        a.attrs.iter().for_each(|attr| {
185            s.with_attr(attr.to_token_stream());
186        });
187        s
188    }
189}
190
191fn do_import_types(item: TokenStream) -> Result<TokenStream, syn::Error> {
192    // Allow the caller to give us either a simple string or a compound object.
193    let (schema, settings) = if let Ok(ll) = syn::parse::<LitStr>(item.clone()) {
194        (ll, TypeSpaceSettings::default())
195    } else {
196        let MacroSettings {
197            schema,
198            derives,
199            replace,
200            patch,
201            struct_builder,
202            convert,
203            unknown_crates,
204            crates,
205            map_type,
206            attrs,
207        } = serde_tokenstream::from_tokenstream(&item.into())?;
208        let mut settings = TypeSpaceSettings::default();
209        derives.into_iter().for_each(|derive| {
210            settings.with_derive(derive.to_token_stream().to_string());
211        });
212        attrs.into_iter().for_each(|attr| {
213            settings.with_attr(attr.to_token_stream().to_string());
214        });
215        settings.with_struct_builder(struct_builder);
216
217        patch.into_iter().for_each(|(type_name, patch)| {
218            settings.with_patch(type_name.to_token_stream(), &patch.into());
219        });
220        replace.into_iter().for_each(|(type_name, type_and_impls)| {
221            let (replace_type, impls) = type_and_impls.into_inner().into_name_and_impls();
222            settings.with_replacement(type_name.to_token_stream(), replace_type, impls.into_iter());
223        });
224        convert.into_iter().for_each(|(schema, type_and_impls)| {
225            let (type_name, impls) = type_and_impls.into_inner().into_name_and_impls();
226            settings.with_conversion(schema, type_name, impls);
227        });
228
229        crates.into_iter().for_each(
230            |(CrateName(crate_name), MacroCrateSpec { original, version })| {
231                if let Some(original_crate) = original {
232                    settings.with_crate(original_crate, version, Some(&crate_name));
233                } else {
234                    settings.with_crate(crate_name, version, None);
235                }
236            },
237        );
238        settings.with_unknown_crates(unknown_crates);
239
240        if let Some(map_type) = map_type {
241            settings.with_map_type(MapType(map_type.into_inner()));
242        }
243
244        (schema.into_inner(), settings)
245    };
246
247    let dir = std::env::var("CARGO_MANIFEST_DIR").map_or_else(
248        |_| std::env::current_dir().unwrap(),
249        |s| Path::new(&s).to_path_buf(),
250    );
251
252    let path = dir.join(schema.value());
253
254    let root_schema: schemars::schema::RootSchema =
255        serde_json::from_reader(std::fs::File::open(&path).map_err(|e| {
256            syn::Error::new(
257                schema.span(),
258                format!("couldn't read file {}: {}", schema.value(), e),
259            )
260        })?)
261        .unwrap();
262
263    let mut type_space = TypeSpace::new(&settings);
264    type_space
265        .add_root_schema(root_schema)
266        .map_err(|e| into_syn_err(e, schema.span()))?;
267
268    let path_str = path.to_string_lossy();
269    let output = quote! {
270        #type_space
271
272        // Force a rebuild when the given file is modified.
273        const _: &str = include_str!(#path_str);
274    };
275
276    Ok(output.into())
277}
278
279fn into_syn_err(e: typify_impl::Error, span: proc_macro2::Span) -> syn::Error {
280    syn::Error::new(span, e.to_string())
281}
282
283#[cfg(test)]
284mod tests {
285    use quote::quote;
286
287    use crate::MacroSettings;
288
289    #[test]
290    fn test_settings() {
291        let item = quote! {
292            schema = "foo.json",
293            derives = [::foo::Foo, ::bar::Bar],
294            replace = {
295                Baz = ::baz::Baz,
296            },
297            struct_builder = true,
298            map_type = ::my::map::Type,
299        };
300
301        let MacroSettings { .. } = serde_tokenstream::from_tokenstream(&item).unwrap();
302    }
303}