smithy_bindgen/
lib.rs

1///! smithy-bindgen macros
2///!
3use proc_macro2::Span;
4use serde::{Deserialize, Serialize};
5use std::{collections::BTreeMap, path::PathBuf, str::FromStr};
6use syn::{
7    bracketed, parse::Parse, parse::ParseStream, parse::Result, punctuated::Punctuated,
8    spanned::Spanned, token, Error, LitStr, Token,
9};
10use weld_codegen::{
11    config::{ModelSource, OutputFile},
12    generators::{CodeGen, RustCodeGen},
13    render::Renderer,
14    sources_to_model,
15    writer::Writer,
16};
17
18const BASE_MODEL_URL: &str = "https://cdn.jsdelivr.net/gh/wasmcloud/interfaces";
19const CORE_MODEL: &str = "core/wasmcloud-core.smithy";
20const MODEL_MODEL: &str = "core/wasmcloud-model.smithy";
21
22/// Generate code from a smithy IDL file.
23///
24/// ## Syntax
25///
26/// The first parameter of the `smithy_bindgen!` macro can take one of three forms.
27/// The second parameter is the namespace used for code generation.
28///
29/// - one wasmcloud first-party interface  
30///
31///   The single-file parameter is a path relative to the wasmcloud interfaces git repo `wasmcloud/interfaces`
32///
33///   ```
34///   # use smithy_bindgen::smithy_bindgen;
35///   smithy_bindgen!("httpserver/httpserver.smithy", "org.wasmcloud.interfaces.httpserver");
36///   ````
37///
38///   The above is shorthand for the following:
39///   ```
40///   # use smithy_bindgen::smithy_bindgen;
41///   smithy_bindgen!({
42///     url: "https://cdn.jsdelivr.net/gh/wasmcloud/interfaces",
43///     files: ["httpserver/httpserver.smithy"]
44///   }, "org.wasmcloud.interfaces.httpserver" );
45///   ```
46///
47/// - one Model Source
48///
49///   ```
50///   # use smithy_bindgen::smithy_bindgen;
51///   smithy_bindgen!({
52///     path: "./tests/test-bindgen.smithy",
53///   }, "org.example.interfaces.foo" );
54///   ````
55///
56/// - array of Model Sources
57///
58///   ```
59///   # use smithy_bindgen::smithy_bindgen;
60///   smithy_bindgen!([
61///     { path: "./tests/test-bindgen.smithy" },
62///     { url: "https://cdn.jsdelivr.net/gh/wasmcloud/interfaces/factorial/factorial.smithy" },
63///   ], "org.example.interfaces.foo" );
64///   ```
65///
66/// ## Model Source Specification
67///
68/// A model source contains a `url`, for http(s) downloads, or a `path`, for local fs access, that serves as a base, plus `files`, an optional list of file paths that are appended to the base to build complete url download paths and local file paths.
69/// When joining the sub-paths from the `files` array, '/' is inserted or removed as needed, so that there is exactly one between the base and the sub-path.
70/// `url` must begin with either 'http://' or 'https://'. If `path` is a relative fs path, it is relative to the folder containing `Cargo.toml`.
71/// `files` may be omitted if the `url` or `path` contains the full path to the `.smithy` file.
72///
73/// All the following are (syntactically) valid model sources:
74/// ```
75/// { url: "https://example.com/interfaces/foo.smithy" }
76/// { url: "https://example.com/interfaces", files: [ "foo.smithy", "bar.smithy" ]}
77/// { path: "../interfaces/foo.smithy" }
78/// { path: "../interfaces", files: ["foo.smithy", "bar.smithy"]}
79/// ```
80///
81/// If a model source structure contains no url base and no path base,
82/// the url for the github wasmcloud interface repo is used:
83/// ```
84/// url: "https://cdn.jsdelivr.net/gh/wasmcloud/interfaces"
85/// ```
86///
87/// Why would the code generator need to load more than one smithy file? So that interfaces can share common symbols for data structures. Most smithy interfaces already import symbols from the namespace `org.wasmcloud.model`, defined in `wasmcloud-model.smithy`.
88/// The bindgen tool resolves all symbols by assembling an in-memory schema model from all the smithy sources and namespaces, then traversing through the in-memory model, generating code only for the schema elements in the namespace declared in the second parameter of `smithy_bindgen!`.
89///
90/// ## jsdelivr.net urls
91///
92/// `cdn.jsdelivr.net` mirrors open source github repositories.
93/// The [url syntax](https://www.jsdelivr.com/?docs=gh) can optionally include
94/// a github branch, tag, or commit sha.
95///
96/// ## Common files
97///
98/// Wasmcloud common model files are always automatically included when compiling models
99/// (If you've used `codegen.toml` files, you may remember that they required all base models
100/// to be specified explicitly.)
101///
102/// ## Namespace
103///
104/// Models may include symbols defined in other models via the `use` command.
105/// Only the symbols defined in the namespace (`smithy_bindgen!`'s second parameter)
106/// will be included in the generated code.
107#[proc_macro]
108pub fn smithy_bindgen(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
109    let bindgen = syn::parse_macro_input!(input as BindgenConfig);
110    generate_source(bindgen)
111        .unwrap_or_else(syn::Error::into_compile_error)
112        .into()
113}
114
115/// parse sources into smithy ast model, then write 'namespace' to generated code
116fn generate_source(bindgen: BindgenConfig) -> Result<proc_macro2::TokenStream> {
117    let call_site = Span::call_site();
118    let sources = bindgen
119        .sources
120        .into_iter()
121        .map(SmithySource::into)
122        .collect::<Vec<ModelSource>>();
123    let mut w = Writer::default();
124    let model = sources_to_model(&sources, &PathBuf::new(), 0).map_err(|e| {
125        Error::new(
126            call_site.span(),
127            format!("cannot compile model sources: {}", e),
128        )
129    })?;
130    let mut rust_gen = RustCodeGen::new(Some(&model));
131    let output_config = OutputFile {
132        namespace: Some(bindgen.namespace),
133        ..Default::default()
134    };
135    let mut params = BTreeMap::<String, serde_json::Value>::default();
136    params.insert("model".into(), atelier_json::model_to_json(&model));
137    let mut renderer = Renderer::default();
138    let bytes = rust_gen
139        .init(Some(&model), &Default::default(), None, &mut renderer)
140        .and_then(|_| rust_gen.generate_file(&mut w, &model, &output_config, &params))
141        .map_err(|e| {
142            Error::new(
143                call_site.span(),
144                format!("cannot generate rust source: {}", e),
145            )
146        })?;
147    proc_macro2::TokenStream::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| {
148        Error::new(
149            call_site.span(),
150            format!("cannot parse generated code: {}", e),
151        )
152    })
153}
154
155#[derive(Debug, Default, Serialize, Deserialize)]
156struct SmithySource {
157    url: Option<String>,
158    path: Option<String>,
159    files: Vec<String>,
160}
161
162/// internal struct used by smithy-bindgen
163#[derive(Debug, Default, Serialize, Deserialize)]
164struct BindgenConfig {
165    pub sources: Vec<SmithySource>,
166    pub namespace: String,
167}
168
169impl From<SmithySource> for ModelSource {
170    fn from(source: SmithySource) -> Self {
171        match (source.url, source.path) {
172            (Some(url), _) => ModelSource::Url { url, files: source.files },
173            (_, Some(path)) => ModelSource::Path { path: path.into(), files: source.files },
174            _ => unreachable!(),
175        }
176    }
177}
178
179mod kw {
180    syn::custom_keyword!(url);
181    syn::custom_keyword!(path);
182    syn::custom_keyword!(files);
183}
184
185enum Opt {
186    Url(String),
187    Path(String),
188    Files(Vec<String>),
189}
190
191impl Parse for Opt {
192    fn parse(input: ParseStream<'_>) -> Result<Self> {
193        let l = input.lookahead1();
194        if l.peek(kw::url) {
195            input.parse::<kw::url>()?;
196            input.parse::<Token![:]>()?;
197            Ok(Opt::Url(input.parse::<LitStr>()?.value()))
198        } else if l.peek(kw::path) {
199            input.parse::<kw::path>()?;
200            input.parse::<Token![:]>()?;
201            Ok(Opt::Path(input.parse::<LitStr>()?.value()))
202        } else if l.peek(kw::files) {
203            input.parse::<kw::files>()?;
204            input.parse::<Token![:]>()?;
205            let content;
206            let _array = bracketed!(content in input);
207            let files = Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?
208                .into_iter()
209                .map(|val| val.value())
210                .collect();
211            Ok(Opt::Files(files))
212        } else {
213            Err(l.error())
214        }
215    }
216}
217
218impl Parse for SmithySource {
219    fn parse(input: ParseStream<'_>) -> syn::parse::Result<Self> {
220        let call_site = Span::call_site();
221        let mut source = SmithySource::default();
222        let content;
223        syn::braced!(content in input);
224        let fields = Punctuated::<Opt, Token![,]>::parse_terminated(&content)?;
225        for field in fields.into_pairs() {
226            match field.into_value() {
227                Opt::Url(s) => {
228                    if source.url.is_some() {
229                        return Err(Error::new(s.span(), "cannot specify second url"));
230                    }
231                    if source.path.is_some() {
232                        return Err(Error::new(s.span(), "cannot specify path and url"));
233                    }
234                    source.url = Some(s)
235                }
236                Opt::Path(s) => {
237                    if source.path.is_some() {
238                        return Err(Error::new(s.span(), "cannot specify second path"));
239                    }
240                    if source.url.is_some() {
241                        return Err(Error::new(s.span(), "cannot specify path and url"));
242                    }
243                    source.path = Some(s)
244                }
245                Opt::Files(val) => source.files = val,
246            }
247        }
248        if !(!source.files.is_empty()
249            || (source.url.is_some() && source.url.as_ref().unwrap().ends_with(".smithy"))
250            || (source.path.is_some() && source.path.as_ref().unwrap().ends_with(".smithy")))
251        {
252            return Err(Error::new(
253                call_site.span(),
254                "There must be at least one .smithy file",
255            ));
256        }
257        if source.url.is_none() && source.path.is_none() {
258            source.url = Some(BASE_MODEL_URL.to_string());
259        }
260        Ok(source)
261    }
262}
263
264impl Parse for BindgenConfig {
265    fn parse(input: ParseStream<'_>) -> syn::parse::Result<Self> {
266        let call_site = Span::call_site();
267        let mut sources;
268
269        let l = input.lookahead1();
270        if l.peek(token::Brace) {
271            // one source
272            let source = input.parse::<SmithySource>()?;
273            sources = vec![source];
274        } else if l.peek(token::Bracket) {
275            // list of sources
276            let content;
277            syn::bracketed!(content in input);
278            sources = Punctuated::<SmithySource, Token![,]>::parse_terminated(&content)?
279                .into_iter()
280                .collect();
281        } else if l.peek(LitStr) {
282            // shorthand for wasmcloud default url
283            let one_file = input.parse::<LitStr>()?;
284            sources = vec![SmithySource {
285                url: Some(BASE_MODEL_URL.into()),
286                path: None,
287                files: vec![
288                    "core/wasmcloud-core.smithy".into(),
289                    "core/wasmcloud-model.smithy".into(),
290                    one_file.value(),
291                ],
292            }];
293        } else {
294            return Err(Error::new(
295                call_site.span(),
296                "expected quoted path, or model source { url or path: ...,  files: ,.. }, or list of model sources [...]"
297            ));
298        }
299        input.parse::<Token![,]>()?;
300        let namespace = input.parse::<LitStr>()?.value();
301
302        // append base models if either are missing
303        let has_core = sources.iter().any(|s| {
304            (s.url.is_some() && s.url.as_ref().unwrap().ends_with(CORE_MODEL))
305                || s.files.iter().any(|s| s.ends_with(CORE_MODEL))
306        });
307        let has_model = sources.iter().any(|s| {
308            (s.url.is_some() && s.url.as_ref().unwrap().ends_with(MODEL_MODEL))
309                || s.files.iter().any(|s| s.ends_with(MODEL_MODEL))
310        });
311        if !has_core || !has_model {
312            sources.push(SmithySource {
313                url: Some(BASE_MODEL_URL.into()),
314                files: match (has_core, has_model) {
315                    (false, false) => vec![CORE_MODEL.into(), MODEL_MODEL.into()],
316                    (false, true) => vec![CORE_MODEL.into()],
317                    (true, false) => vec![MODEL_MODEL.into()],
318                    _ => unreachable!(),
319                },
320                path: None,
321            });
322        }
323        Ok(BindgenConfig { sources, namespace })
324    }
325}