diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..82632b972 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: 'bug: ' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Create the following type '....' +3. Derive/implement `TS` +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Version** +What version of the library are you using? + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..cd72dd022 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: 'Feature request: ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..6f3670c80 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Goal + +What is this PR attempting to achieve? Is it a bug fix? Is it related to an issue? +Closes # + +## Changes + +How did you go about solving the problem? + +## Checklist + +- [ ] I have followed the steps listed in the [Contributing guide](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md). +- [ ] If necessary, I have added documentation related to the changes made. +- [ ] I have added or updated the tests related to the changes made. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab65bf9a..d1d01ea03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ### Breaking +- `#[serde(with = "...")]` requires the use of `#[ts(as = "...")]` or `#[ts(type = "...")]` ([#280](https://github.com/Aleph-Alpha/ts-rs/pull/280)) +- Fix incompatibility with serde for `snake_case`, `kebab-case` and `SCREAMING_SNAKE_CASE` ([#298](https://github.com/Aleph-Alpha/ts-rs/pull/298)) +- `#[ts(rename_all = "...")]` no longer accepts variations in the string's casing, dashes and underscores to make behavior consistent with serde ([#298](https://github.com/Aleph-Alpha/ts-rs/pull/298)) + +### Features + +- Add support for `#[ts(type = "..")]` directly on structs and enums ([#286](https://github.com/Aleph-Alpha/ts-rs/pull/286)) +- Add support for `#[ts(as = "..")]` directly on structs and enums ([#288](https://github.com/Aleph-Alpha/ts-rs/pull/288)) +- Add support for `#[ts(rename_all = "SCREAMING-KEBAB-CASE")]` ([#298](https://github.com/Aleph-Alpha/ts-rs/pull/298)) + +### Fixes + +- Fix `#[ts(rename_all_fields = "...")]` on enums containing tuple or unit variants ([#287](https://github.com/Aleph-Alpha/ts-rs/pull/287)) + +# 8.1.0 + +### Breaking + +### Features + +- Add `#[ts(crate = "..")]` to allow usage of `#[derive(TS)]` from other proc-macro crates ([#274](https://github.com/Aleph-Alpha/ts-rs/pull/274)) +- Add support types from `serde_json` behind cargo feature `serde-json-impl` ([#276](https://github.com/Aleph-Alpha/ts-rs/pull/276)) + +### Fixes + +- Macro expansion for types with generic parameters now works without the `TS` trait in scope ([#281](https://github.com/Aleph-Alpha/ts-rs/pull/281)) +- Fix enum flattening a struct that contains a flattened enum ([#282](https://github.com/Aleph-Alpha/ts-rs/pull/282)) + +# v8.0.0 + +### Breaking + - Export types as `type` instead of `ìnterface` ([#203](https://github.com/Aleph-Alpha/ts-rs/pull/203)) - Automatically export all dependencies when using `#[ts(export)]`, add `TS::dependency_types()` ([#221](https://github.com/Aleph-Alpha/ts-rs/pull/221)) - Remove support for "skip_serializing", "skip_serializing_if" and "skip_deserializing". ([#204](https://github.com/Aleph-Alpha/ts-rs/pull/204)) @@ -10,8 +42,10 @@ - Added `TS::dependency_types()` ([#221](https://github.com/Aleph-Alpha/ts-rs/pull/221)) - Added `TS::generics()` ([#241](https://github.com/Aleph-Alpha/ts-rs/pull/241)) - Added `TS::WithoutGenerics` ([#241](https://github.com/Aleph-Alpha/ts-rs/pull/241)) -- `Result`, `Option`, `HashMap` and `Vec` had their implementations of `TS` changed ([#241](https://github.com/Aleph-Alpha/ts-rs/pull/241)) - Removed `TS::transparent()` ([#243](https://github.com/Aleph-Alpha/ts-rs/pull/243)) +- Handling of output paths ([#247](https://github.com/Aleph-Alpha/ts-rs/pull/247), [#250](https://github.com/Aleph-Alpha/ts-rs/pull/250), [#256](https://github.com/Aleph-Alpha/ts-rs/pull/256)) + - All paths specified using `#[ts(export_to = "...")]` are now relative to `TS_RS_EXPORT_DIR`, which defaults to `./bindings/` +- Replace `TS::export` with `TS::export`, `TS::export_all` and `TS::export_to_all` ([#263](https://github.com/Aleph-Alpha/ts-rs/pull/263)) ### Features @@ -27,11 +61,15 @@ - Support `#[serde(untagged)]` on individual enum variants ([#226](https://github.com/Aleph-Alpha/ts-rs/pull/226)) - Support for `#[serde(rename_all_fields = "...")]` ([#225](https://github.com/Aleph-Alpha/ts-rs/pull/225)) - Export Rust doc comments/attributes on structs/enums as TSDoc strings ([#187](https://github.com/Aleph-Alpha/ts-rs/pull/187)) +- `Result`, `Option`, `HashMap` and `Vec` had their implementations of `TS` changed ([#241](https://github.com/Aleph-Alpha/ts-rs/pull/241)) - Implement `#[ts(...)]` equivalent for `#[serde(tag = "...")]` being used on a struct with named fields ([#244](https://github.com/Aleph-Alpha/ts-rs/pull/244)) +- Implement `#[ts(concrete(..))]` to specify a concrete type for a generic parameter ([#264](https://github.com/Aleph-Alpha/ts-rs/pull/264)) ### Fixes -- fix `#[ts(skip)]` and `#[serde(skip)]` in variants of adjacently or internally tagged enums ([#231](https://github.com/Aleph-Alpha/ts-rs/pull/231)) +- Fix `#[ts(skip)]` and `#[serde(skip)]` in variants of adjacently or internally tagged enums ([#231](https://github.com/Aleph-Alpha/ts-rs/pull/231)) - `rename_all` with `camelCase` produces wrong names if fields were already in camelCase ([#198](https://github.com/Aleph-Alpha/ts-rs/pull/198)) - Improve support for references ([#199](https://github.com/Aleph-Alpha/ts-rs/pull/199)) - Generic type aliases generate correctly ([#233](https://github.com/Aleph-Alpha/ts-rs/pull/233)) +- Improve compiler errors ([#257](https://github.com/Aleph-Alpha/ts-rs/pull/257)) +- Update dependencies ([#255](https://github.com/Aleph-Alpha/ts-rs/pull/255)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 485516b0a..21910e22d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,4 +22,11 @@ There is nothing special going on here - just run `cargo build`. To run the test suite, just run `cargo test` in the root directory. ### Formatting -To ensure proper formatting, please run `cargo +nightly fmt`. +To ensure proper formatting, please make sure you have the nigthly toolchain installed. +After that, in the project's root directory, create a file called `.git/hooks/pre-commit` without a file extension and add the following two lines: +```sh +#!/bin/sh +cargo +nightly fmt +``` + +This will make sure your files are formatted before your commit is sent, so you don't have to manually run `cargo +nightly fmt` diff --git a/Cargo.toml b/Cargo.toml index 547f21f56..df5d78966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["config", "macros", "ts-rs", "example"] +members = ["macros", "ts-rs", "example"] resolver = "2" diff --git a/README.md b/README.md index 8f53e1392..623ca0b2e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ts-rs

-generate typescript type declarations from rust types +Generate typescript type declarations from rust types

@@ -26,23 +26,23 @@ alt="Download" />
-### why? +### Why? When building a web application in rust, data structures have to be shared between backend and frontend. Using this library, you can easily generate TypeScript bindings to your rust structs & enums so that you can keep your types in one place. ts-rs might also come in handy when working with webassembly. -### how? +### How? ts-rs exposes a single trait, `TS`. Using a derive macro, you can implement this interface for your types. Then, you can use this trait to obtain the TypeScript bindings. We recommend doing this in your tests. [See the example](https://github.com/Aleph-Alpha/ts-rs/blob/main/example/src/lib.rs) and [the docs](https://docs.rs/ts-rs/latest/ts_rs/). -### get started +### Get started ```toml [dependencies] -ts-rs = "7.1" +ts-rs = "8.1" ``` ```rust @@ -58,7 +58,7 @@ struct User { ``` When running `cargo test`, the TypeScript bindings will be exported to the file `bindings/User.ts`. -### features +### Features - generate type declarations from rust structs - generate union declarations from rust enums - inline types @@ -75,6 +75,7 @@ When running `cargo test`, the TypeScript bindings will be exported to the file | format | Enables formatting of the generated TypeScript bindings.
Currently, this unfortunately adds quite a few dependencies. | | no-serde-warnings | By default, warnings are printed during build if unsupported serde attributes are encountered.
Enabling this feature silences these warnings. | | import-esm | When enabled,`import` statements in the generated file will have the `.js` extension in the end of the path to conform to the ES Modules spec.
Example: `import { MyStruct } from "./my_struct.js"` | +| serde-json-impl | Implement `TS` for types from *serde_json* | | chrono-impl | Implement `TS` for types from *chrono* | | bigdecimal-impl | Implement `TS` for types from *bigdecimal* | | url-impl | Implement `TS` for types from *url* | @@ -91,7 +92,7 @@ When running `cargo test`, the TypeScript bindings will be exported to the file If there's a type you're dealing with which doesn't implement `TS`, use either `#[ts(as = "..")]` or `#[ts(type = "..")]`, or open a PR. -### serde compatability +### `serde` compatability With the `serde-compat` feature (enabled by default), serde attributes can be parsed for enums and structs. Supported serde attributes: - `rename` @@ -109,17 +110,12 @@ from the generated type, but cannot use `#[serde(skip)]`, use `#[ts(skip)]` inst When ts-rs encounters an unsupported serde attribute, a warning is emitted, unless the feature `no-serde-warnings` is enabled. -### contributing +### Contributing Contributions are always welcome! Feel free to open an issue, discuss using GitHub discussions or open a PR. [See CONTRIBUTING.md](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md) -### todo -- [x] serde compatibility layer -- [x] documentation -- [x] use typescript types across files -- [x] more enum representations -- [x] generics -- [x] don't require `'static` +### MSRV +The Minimum Supported Rust Version for this crate is 1.75.0 License: MIT diff --git a/config/Cargo.toml b/config/Cargo.toml deleted file mode 100644 index 72af9577d..000000000 --- a/config/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "ts-rs-config" -version = "5.1.0" -edition = "2021" - -[dependencies] -serde = { version = "1", features = ["derive"] } -toml = "0.5" -anyhow = "1" -once_cell = "1.8" \ No newline at end of file diff --git a/config/README.md b/config/README.md deleted file mode 100644 index 25ce33371..000000000 --- a/config/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# ts-rs-config -This crate contains the config for future ts-rs versions. -Currently, it's not really possible no use the config within the proc macro due to issues regarding incremental compilation. -A workaround would be to have `include_str!("ts.toml")` everywhere, but I don't think we should do that. -Instead, let's wait for https://github.com/rust-lang/rust/issues/73921 \ No newline at end of file diff --git a/config/src/lib.rs b/config/src/lib.rs deleted file mode 100644 index 50b86d97e..000000000 --- a/config/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; - -use anyhow::Result; -use once_cell::sync::OnceCell; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct Config { - ambient_declarations: bool, - out_dir: String, -} - -impl Default for Config { - fn default() -> Self { - Self { - ambient_declarations: false, - out_dir: "typescript".to_owned(), - } - } -} - -static CONFIG_INSTANCE: OnceCell> = OnceCell::new(); - -impl Config { - const FILE_NAME: &'static str = "ts.toml"; - - pub fn get() -> Result> { - match CONFIG_INSTANCE.get() { - None => { - let cfg = Arc::new(Self::load()?); - CONFIG_INSTANCE.set(cfg.clone()).ok(); - Ok(cfg) - } - Some(cfg) => Ok(cfg.clone()), - } - } - - fn load() -> Result { - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); - let config = Self::try_load_from_dir(&manifest_dir)?.unwrap_or_default(); - Ok(config) - } - - fn try_load_from_dir(dir: &Path) -> Result> { - let path = { - let mut path = PathBuf::from(dir); - path.push(Self::FILE_NAME); - path - }; - match path.is_file() { - true => { - let content = std::fs::read_to_string(path)?; - let parsed = toml::from_str::(&content)?; - Ok(Some(parsed)) - } - false => Ok(None), - } - } -} diff --git a/e2e/workspace/Cargo.toml b/e2e/workspace/Cargo.toml index d06ca9311..471e90b02 100644 --- a/e2e/workspace/Cargo.toml +++ b/e2e/workspace/Cargo.toml @@ -1,2 +1,3 @@ [workspace] -members = ["crate1", "crate2", "parent"] \ No newline at end of file +members = ["crate1", "crate2", "parent", "renamed"] +resolver = "2" diff --git a/e2e/workspace/renamed/Cargo.toml b/e2e/workspace/renamed/Cargo.toml new file mode 100644 index 000000000..aad7856cd --- /dev/null +++ b/e2e/workspace/renamed/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "renamed" +version = "0.1.0" +edition = "2021" + +[dependencies] +ts-renamed = { package = "ts-rs", path = "../../../ts-rs" } diff --git a/e2e/workspace/renamed/src/main.rs b/e2e/workspace/renamed/src/main.rs new file mode 100644 index 000000000..fa6cf12e5 --- /dev/null +++ b/e2e/workspace/renamed/src/main.rs @@ -0,0 +1,12 @@ +use ts_renamed::TS; + +#[derive(TS)] +#[ts(crate = "ts_renamed", export)] +pub struct SimpleStruct { + hello: String, + world: u32, +} + +fn main() { + println!("Hello, world!"); +} diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 9897b675c..7373256de 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ts-rs-macros" -version = "7.1.1" +version = "8.1.0" authors = ["Moritz Bischof "] edition = "2021" description = "derive macro for ts-rs" @@ -19,5 +19,4 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "2.0.28", features = ["full", "extra-traits"] } -Inflector = { version = "0.11", default-features = false } termcolor = { version = "1", optional = true } diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs index dad4fdff5..5532a69be 100644 --- a/macros/src/attr/enum.rs +++ b/macros/src/attr/enum.rs @@ -1,27 +1,31 @@ -use syn::{Attribute, Ident, Result}; +use std::collections::HashMap; +use syn::{parse_quote, Attribute, Ident, ItemEnum, Path, Result, Type, WherePredicate}; + +use super::{parse_assign_from_str, parse_bound, Attr, ContainerAttr, Serde}; use crate::{ - attr::{parse_assign_inflection, parse_assign_str, Inflection}, + attr::{parse_assign_inflection, parse_assign_str, parse_concrete, Inflection}, utils::{parse_attrs, parse_docs}, }; #[derive(Default)] pub struct EnumAttr { + crate_rename: Option, + pub type_as: Option, + pub type_override: Option, pub rename_all: Option, pub rename_all_fields: Option, pub rename: Option, pub export_to: Option, pub export: bool, pub docs: String, - tag: Option, - untagged: bool, - content: Option, + pub concrete: HashMap, + pub bound: Option>, + pub tag: Option, + pub untagged: bool, + pub content: Option, } -#[cfg(feature = "serde-compat")] -#[derive(Default)] -pub struct SerdeEnumAttr(EnumAttr); - #[derive(Copy, Clone)] pub enum Tagged<'a> { Externally, @@ -44,45 +48,168 @@ impl EnumAttr { } pub fn from_attrs(attrs: &[Attribute]) -> Result { - let mut result = Self::default(); - parse_attrs(attrs)?.for_each(|a| result.merge(a)); + let mut result = parse_attrs::(attrs)?; + + #[cfg(feature = "serde-compat")] + { + let serde_attr = crate::utils::parse_serde_attrs::(attrs); + result = result.merge(serde_attr.0); + } let docs = parse_docs(attrs)?; result.docs = docs; - #[cfg(feature = "serde-compat")] - crate::utils::parse_serde_attrs::(attrs).for_each(|a| result.merge(a.0)); Ok(result) } - fn merge( - &mut self, - EnumAttr { - rename_all, - rename_all_fields, - rename, - tag, - content, - untagged, - export_to, - export, - docs, - }: EnumAttr, - ) { - self.rename = self.rename.take().or(rename); - self.rename_all = self.rename_all.take().or(rename_all); - self.rename_all_fields = self.rename_all_fields.take().or(rename_all_fields); - self.tag = self.tag.take().or(tag); - self.untagged = self.untagged || untagged; - self.content = self.content.take().or(content); - self.export = self.export || export; - self.export_to = self.export_to.take().or(export_to); - self.docs = docs; + pub fn crate_rename(&self) -> Path { + self.crate_rename + .clone() + .unwrap_or_else(|| parse_quote!(::ts_rs)) + } +} + +impl Attr for EnumAttr { + type Item = ItemEnum; + + fn merge(self, other: Self) -> Self { + Self { + crate_rename: self.crate_rename.or(other.crate_rename), + type_as: self.type_as.or(other.type_as), + type_override: self.type_override.or(other.type_override), + rename: self.rename.or(other.rename), + rename_all: self.rename_all.or(other.rename_all), + rename_all_fields: self.rename_all_fields.or(other.rename_all_fields), + tag: self.tag.or(other.tag), + untagged: self.untagged || other.untagged, + content: self.content.or(other.content), + export: self.export || other.export, + export_to: self.export_to.or(other.export_to), + docs: other.docs, + concrete: self.concrete.into_iter().chain(other.concrete).collect(), + bound: match (self.bound, other.bound) { + (Some(a), Some(b)) => Some(a.into_iter().chain(b).collect()), + (Some(bound), None) | (None, Some(bound)) => Some(bound), + (None, None) => None, + }, + } + } + + fn assert_validity(&self, item: &Self::Item) -> Result<()> { + if self.type_override.is_some() { + if self.type_as.is_some() { + syn_err_spanned!( + item; + "`as` is not compatible with `type`" + ); + } + + if self.rename_all.is_some() { + syn_err_spanned!( + item; + "`rename_all` is not compatible with `type`" + ); + } + + if self.rename_all_fields.is_some() { + syn_err_spanned!( + item; + "`rename_all_fields` is not compatible with `type`" + ); + } + + if self.tag.is_some() { + syn_err_spanned!( + item; + "`tag` is not compatible with `type`" + ); + } + + if self.content.is_some() { + syn_err_spanned!( + item; + "`content` is not compatible with `type`" + ); + } + + if self.untagged { + syn_err_spanned!( + item; + "`untagged` is not compatible with `type`" + ); + } + } + + if self.type_as.is_some() { + if self.rename_all.is_some() { + syn_err_spanned!( + item; + "`rename_all` is not compatible with `as`" + ); + } + + if self.rename_all_fields.is_some() { + syn_err_spanned!( + item; + "`rename_all_fields` is not compatible with `as`" + ); + } + + if self.tag.is_some() { + syn_err_spanned!( + item; + "`tag` is not compatible with `as`" + ); + } + + if self.content.is_some() { + syn_err_spanned!( + item; + "`content` is not compatible with `as`" + ); + } + + if self.untagged { + syn_err_spanned!( + item; + "`untagged` is not compatible with `as`" + ); + } + } + + match (self.untagged, &self.tag, &self.content) { + (true, Some(_), None) => syn_err_spanned!( + item; + "untagged cannot be used with tag" + ), + (true, _, Some(_)) => syn_err_spanned!( + item; + "untagged cannot be used with content" + ), + (false, None, Some(_)) => syn_err_spanned!( + item; + "content cannot be used without tag" + ), + _ => (), + }; + + Ok(()) + } +} + +impl ContainerAttr for EnumAttr { + fn crate_rename(&self) -> Path { + self.crate_rename + .clone() + .unwrap_or_else(|| parse_quote!(::ts_rs)) } } impl_parse! { EnumAttr(input, out) { + "crate" => out.crate_rename = Some(parse_assign_from_str(input)?), + "as" => out.type_as = Some(parse_assign_from_str(input)?), + "type" => out.type_override = Some(parse_assign_str(input)?), "rename" => out.rename = Some(parse_assign_str(input)?), "rename_all" => out.rename_all = Some(parse_assign_inflection(input)?), "rename_all_fields" => out.rename_all_fields = Some(parse_assign_inflection(input)?), @@ -90,18 +217,21 @@ impl_parse! { "export" => out.export = true, "tag" => out.tag = Some(parse_assign_str(input)?), "content" => out.content = Some(parse_assign_str(input)?), - "untagged" => out.untagged = true + "untagged" => out.untagged = true, + "concrete" => out.concrete = parse_concrete(input)?, + "bound" => out.bound = Some(parse_bound(input)?), } } #[cfg(feature = "serde-compat")] impl_parse! { - SerdeEnumAttr(input, out) { + Serde(input, out) { "rename" => out.0.rename = Some(parse_assign_str(input)?), "rename_all" => out.0.rename_all = Some(parse_assign_inflection(input)?), "rename_all_fields" => out.0.rename_all_fields = Some(parse_assign_inflection(input)?), "tag" => out.0.tag = Some(parse_assign_str(input)?), "content" => out.0.content = Some(parse_assign_str(input)?), - "untagged" => out.0.untagged = true + "untagged" => out.0.untagged = true, + "bound" => out.0.bound = Some(parse_bound(input)?), } } diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs index 6f93cd9d0..4dc4d2aa4 100644 --- a/macros/src/attr/field.rs +++ b/macros/src/attr/field.rs @@ -1,11 +1,11 @@ -use syn::{Attribute, Ident, Result, Path}; +use syn::{Attribute, Field, Ident, Result, Type}; -use super::{parse_assign_str, parse_assign_from_str}; +use super::{parse_assign_from_str, parse_assign_str, Attr, Serde}; use crate::utils::{parse_attrs, parse_docs}; #[derive(Default)] pub struct FieldAttr { - pub type_as: Option, + pub type_as: Option, pub type_override: Option, pub rename: Option, pub inline: bool, @@ -13,6 +13,9 @@ pub struct FieldAttr { pub optional: Optional, pub flatten: bool, pub docs: String, + + #[cfg(feature = "serde-compat")] + pub using_serde_with: bool, } /// Indicates whether the field is marked with `#[ts(optional)]`. @@ -24,47 +27,131 @@ pub struct Optional { pub nullable: bool, } -#[cfg(feature = "serde-compat")] -#[derive(Default)] -pub struct SerdeFieldAttr(FieldAttr); - impl FieldAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { - let mut result = Self::default(); - parse_attrs(attrs)?.for_each(|a| result.merge(a)); - result.docs = parse_docs(attrs)?; + let mut result = parse_attrs::(attrs)?; + #[cfg(feature = "serde-compat")] if !result.skip { - crate::utils::parse_serde_attrs::(attrs) - .for_each(|a| result.merge(a.0)); + let serde_attr = crate::utils::parse_serde_attrs::(attrs); + result = result.merge(serde_attr.0); } + + result.docs = parse_docs(attrs)?; + Ok(result) } +} + +impl Attr for FieldAttr { + type Item = Field; + + fn merge(self, other: Self) -> Self { + Self { + type_as: self.type_as.or(other.type_as), + type_override: self.type_override.or(other.type_override), + rename: self.rename.or(other.rename), + inline: self.inline || other.inline, + skip: self.skip || other.skip, + optional: Optional { + optional: self.optional.optional || other.optional.optional, + nullable: self.optional.nullable || other.optional.nullable, + }, + flatten: self.flatten || other.flatten, + #[cfg(feature = "serde-compat")] + using_serde_with: self.using_serde_with || other.using_serde_with, + + // We can't emit TSDoc for a flattened field + // and we cant make this invalid in assert_validity because + // this documentation is totally valid in Rust + docs: if self.flatten || other.flatten { + String::new() + } else { + self.docs + &other.docs + }, + } + } + + fn assert_validity(&self, field: &Self::Item) -> Result<()> { + #[cfg(feature = "serde-compat")] + if self.using_serde_with && !(self.type_as.is_some() || self.type_override.is_some()) { + syn_err_spanned!( + field; + r#"using `#[serde(with = "...")]` requires the use of `#[ts(as = "...")]` or `#[ts(type = "...")]`"# + ) + } + + if self.type_override.is_some() { + if self.type_as.is_some() { + syn_err_spanned!(field; "`type` is not compatible with `as`") + } + + if self.inline { + syn_err_spanned!(field; "`type` is not compatible with `inline`") + } + + if self.flatten { + syn_err_spanned!( + field; + "`type` is not compatible with `flatten`" + ); + } + } + + if self.flatten { + if self.type_as.is_some() { + syn_err_spanned!( + field; + "`as` is not compatible with `flatten`" + ); + } + + if self.rename.is_some() { + syn_err_spanned!( + field; + "`rename` is not compatible with `flatten`" + ); + } + + if self.inline { + syn_err_spanned!( + field; + "`inline` is not compatible with `flatten`" + ); + } + + if self.optional.optional { + syn_err_spanned!( + field; + "`optional` is not compatible with `flatten`" + ); + } + } + + if field.ident.is_none() { + if self.flatten { + syn_err_spanned!( + field; + "`flatten` cannot with tuple struct fields" + ); + } + + if self.rename.is_some() { + syn_err_spanned!( + field; + "`flatten` cannot with tuple struct fields" + ); + } - fn merge( - &mut self, - FieldAttr { - type_as, - type_override, - rename, - inline, - skip, - optional: Optional { optional, nullable }, - flatten, - docs, - }: FieldAttr, - ) { - self.rename = self.rename.take().or(rename); - self.type_as = self.type_as.take().or(type_as); - self.type_override = self.type_override.take().or(type_override); - self.inline = self.inline || inline; - self.skip = self.skip || skip; - self.optional = Optional { - optional: self.optional.optional || optional, - nullable: self.optional.nullable || nullable, - }; - self.flatten |= flatten; - self.docs.push_str(&docs); + if self.optional.optional { + syn_err_spanned!( + field; + "`optional` cannot with tuple struct fields" + ); + } + } + + Ok(()) } } @@ -98,7 +185,7 @@ impl_parse! { #[cfg(feature = "serde-compat")] impl_parse! { - SerdeFieldAttr(input, out) { + Serde(input, out) { "rename" => out.0.rename = Some(parse_assign_str(input)?), "skip" => out.0.skip = true, "flatten" => out.0.flatten = true, @@ -106,9 +193,12 @@ impl_parse! { "default" => { use syn::Token; if input.peek(Token![=]) { - input.parse::()?; parse_assign_str(input)?; } }, + "with" => { + parse_assign_str(input)?; + out.0.using_serde_with = true; + }, } } diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs index 72196dc33..196166f3f 100644 --- a/macros/src/attr/mod.rs +++ b/macros/src/attr/mod.rs @@ -1,11 +1,12 @@ -use std::convert::TryFrom; +use std::collections::HashMap; pub use field::*; pub use r#enum::*; pub use r#struct::*; use syn::{ parse::{Parse, ParseStream}, - Error, Lit, Result, Token, + punctuated::Punctuated, + Error, Lit, Path, Result, Token, WherePredicate, }; pub use variant::*; @@ -23,12 +24,38 @@ pub enum Inflection { Pascal, ScreamingSnake, Kebab, + ScreamingKebab, +} + +pub(super) trait Attr: Default { + type Item; + + fn merge(self, other: Self) -> Self; + fn assert_validity(&self, item: &Self::Item) -> Result<()>; +} + +pub(super) trait ContainerAttr: Attr { + fn crate_rename(&self) -> Path; +} + +#[cfg(feature = "serde-compat")] +#[derive(Default)] +pub(super) struct Serde(pub T) +where + T: Attr; + +#[cfg(feature = "serde-compat")] +impl Serde +where + T: Attr, +{ + pub fn merge(self, other: Self) -> Self { + Self(self.0.merge(other.0)) + } } impl Inflection { pub fn apply(self, string: &str) -> String { - use inflector::Inflector; - match self { Inflection::Lower => string.to_lowercase(), Inflection::Upper => string.to_uppercase(), @@ -36,7 +63,18 @@ impl Inflection { let pascal = Inflection::apply(Inflection::Pascal, string); pascal[..1].to_ascii_lowercase() + &pascal[1..] } - Inflection::Snake => string.to_snake_case(), + Inflection::Snake => { + let mut s = String::new(); + + for (i, ch) in string.char_indices() { + if ch.is_uppercase() && i != 0 { + s.push('_'); + } + s.push(ch.to_ascii_lowercase()); + } + + s + } Inflection::Pascal => { let mut s = String::with_capacity(string.len()); @@ -55,29 +93,13 @@ impl Inflection { s } - Inflection::ScreamingSnake => string.to_screaming_snake_case(), - Inflection::Kebab => string.to_kebab_case(), + Inflection::ScreamingSnake => Self::Snake.apply(string).to_ascii_uppercase(), + Inflection::Kebab => Self::Snake.apply(string).replace('_', "-"), + Inflection::ScreamingKebab => Self::Kebab.apply(string).to_ascii_uppercase(), } } } -impl TryFrom for Inflection { - type Error = Error; - - fn try_from(value: String) -> Result { - Ok(match &*value.to_lowercase().replace(['_', '-'], "") { - "lowercase" => Self::Lower, - "uppercase" => Self::Upper, - "camelcase" => Self::Camel, - "snakecase" => Self::Snake, - "pascalcase" => Self::Pascal, - "screamingsnakecase" => Self::ScreamingSnake, - "kebabcase" => Self::Kebab, - _ => syn_err!("invalid inflection: '{}'", value), - }) - } -} - fn parse_assign_str(input: ParseStream) -> Result { input.parse::()?; match Lit::parse(input)? { @@ -86,14 +108,77 @@ fn parse_assign_str(input: ParseStream) -> Result { } } +fn parse_concrete(input: ParseStream) -> Result> { + struct Concrete { + ident: syn::Ident, + _equal_token: Token![=], + ty: syn::Type, + } + + impl Parse for Concrete { + fn parse(input: ParseStream) -> Result { + Ok(Self { + ident: input.parse()?, + _equal_token: input.parse()?, + ty: input.parse()?, + }) + } + } + + let content; + syn::parenthesized!(content in input); + + Ok( + Punctuated::::parse_terminated(&content)? + .into_iter() + .map(|concrete| (concrete.ident, concrete.ty)) + .collect(), + ) +} + fn parse_assign_inflection(input: ParseStream) -> Result { - parse_assign_str(input).and_then(Inflection::try_from) + input.parse::()?; + + match Lit::parse(input)? { + Lit::Str(string) => Ok(match &*string.value() { + "lowercase" => Inflection::Lower, + "UPPERCASE" => Inflection::Upper, + "camelCase" => Inflection::Camel, + "snake_case" => Inflection::Snake, + "PascalCase" => Inflection::Pascal, + "SCREAMING_SNAKE_CASE" => Inflection::ScreamingSnake, + "kebab-case" => Inflection::Kebab, + "SCREAMING-KEBAB-CASE" => Inflection::ScreamingKebab, + other => { + syn_err!( + string.span(); + r#"Value "{other}" is not valid for "rename_all". Accepted values are: "lowercase", "UPPERCASE", "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", "kebab-case" and "SCREAMING-KEBAB-CASE""# + ) + } + }), + other => Err(Error::new(other.span(), "expected string")), + } } -fn parse_assign_from_str(input: ParseStream) -> Result where T: Parse { +fn parse_assign_from_str(input: ParseStream) -> Result +where + T: Parse, +{ input.parse::()?; match Lit::parse(input)? { Lit::Str(string) => string.parse(), other => Err(Error::new(other.span(), "expected string")), } } + +fn parse_bound(input: ParseStream) -> Result> { + input.parse::()?; + match Lit::parse(input)? { + Lit::Str(string) => { + let parser = Punctuated::::parse_terminated; + + Ok(string.parse_with(parser)?.into_iter().collect()) + } + other => Err(Error::new(other.span(), "expected string")), + } +} diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index 0e37e1f0d..69a4b067d 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -1,90 +1,157 @@ -use std::convert::TryFrom; +use std::collections::HashMap; -use syn::{Attribute, Ident, Result}; +use syn::{parse_quote, Attribute, Fields, Ident, Path, Result, Type, WherePredicate}; +use super::{ + parse_assign_from_str, parse_assign_inflection, parse_bound, parse_concrete, Attr, + ContainerAttr, Serde, +}; use crate::{ - attr::{parse_assign_str, Inflection, VariantAttr}, + attr::{parse_assign_str, EnumAttr, Inflection, VariantAttr}, utils::{parse_attrs, parse_docs}, }; #[derive(Default, Clone)] pub struct StructAttr { + crate_rename: Option, + pub type_as: Option, + pub type_override: Option, pub rename_all: Option, pub rename: Option, pub export_to: Option, pub export: bool, pub tag: Option, pub docs: String, + pub concrete: HashMap, + pub bound: Option>, } -#[cfg(feature = "serde-compat")] -#[derive(Default)] -pub struct SerdeStructAttr(StructAttr); - impl StructAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { - let mut result = Self::default(); - parse_attrs(attrs)?.for_each(|a| result.merge(a)); + let mut result = parse_attrs::(attrs)?; + + #[cfg(feature = "serde-compat")] + { + let serde_attr = crate::utils::parse_serde_attrs::(attrs); + result = result.merge(serde_attr.0); + } let docs = parse_docs(attrs)?; result.docs = docs; - #[cfg(feature = "serde-compat")] - crate::utils::parse_serde_attrs::(attrs).for_each(|a| result.merge(a.0)); Ok(result) } - fn merge( - &mut self, - StructAttr { - rename_all, - rename, - export, - export_to, - tag, - docs, - }: StructAttr, - ) { - self.rename = self.rename.take().or(rename); - self.rename_all = self.rename_all.take().or(rename_all); - self.export_to = self.export_to.take().or(export_to); - self.export = self.export || export; - self.tag = self.tag.take().or(tag); - self.docs = docs; - } -} - -impl From for StructAttr { - fn from( - VariantAttr { - rename, rename_all, .. - }: VariantAttr, + pub fn from_variant( + enum_attr: &EnumAttr, + variant_attr: &VariantAttr, + variant_fields: &Fields, ) -> Self { Self { - rename, - rename_all, + crate_rename: Some(enum_attr.crate_rename()), + rename: variant_attr.rename.clone(), + rename_all: variant_attr.rename_all.or(match variant_fields { + Fields::Named(_) => enum_attr.rename_all_fields, + Fields::Unnamed(_) | Fields::Unit => None, + }), // inline and skip are not supported on StructAttr ..Self::default() } } } +impl Attr for StructAttr { + type Item = Fields; + + fn merge(self, other: Self) -> Self { + Self { + crate_rename: self.crate_rename.or(other.crate_rename), + type_as: self.type_as.or(other.type_as), + type_override: self.type_override.or(other.type_override), + rename: self.rename.or(other.rename), + rename_all: self.rename_all.or(other.rename_all), + export_to: self.export_to.or(other.export_to), + export: self.export || other.export, + tag: self.tag.or(other.tag), + docs: other.docs, + concrete: self.concrete.into_iter().chain(other.concrete).collect(), + bound: match (self.bound, other.bound) { + (Some(a), Some(b)) => Some(a.into_iter().chain(b).collect()), + (Some(bound), None) | (None, Some(bound)) => Some(bound), + (None, None) => None, + }, + } + } + + fn assert_validity(&self, item: &Self::Item) -> Result<()> { + if self.type_override.is_some() { + if self.type_as.is_some() { + syn_err!("`as` is not compatible with `type`"); + } + + if self.rename_all.is_some() { + syn_err!("`rename_all` is not compatible with `type`"); + } + + if self.tag.is_some() { + syn_err!("`tag` is not compatible with `type`"); + } + } + + if self.type_as.is_some() { + if self.tag.is_some() { + syn_err!("`tag` is not compatible with `as`"); + } + + if self.rename_all.is_some() { + syn_err!("`rename_all` is not compatible with `as`"); + } + } + + if !matches!(item, Fields::Named(_)) { + if self.tag.is_some() { + syn_err!("`tag` cannot be used with unit or tuple structs"); + } + + if self.rename_all.is_some() { + syn_err!("`rename_all` cannot be used with unit or tuple structs"); + } + } + + Ok(()) + } +} + +impl ContainerAttr for StructAttr { + fn crate_rename(&self) -> Path { + self.crate_rename + .clone() + .unwrap_or_else(|| parse_quote!(::ts_rs)) + } +} + impl_parse! { StructAttr(input, out) { + "crate" => out.crate_rename = Some(parse_assign_from_str(input)?), + "as" => out.type_as = Some(parse_assign_from_str(input)?), + "type" => out.type_override = Some(parse_assign_str(input)?), "rename" => out.rename = Some(parse_assign_str(input)?), - "rename_all" => out.rename_all = Some(parse_assign_str(input).and_then(Inflection::try_from)?), + "rename_all" => out.rename_all = Some(parse_assign_inflection(input)?), "tag" => out.tag = Some(parse_assign_str(input)?), "export" => out.export = true, - "export_to" => out.export_to = Some(parse_assign_str(input)?) + "export_to" => out.export_to = Some(parse_assign_str(input)?), + "concrete" => out.concrete = parse_concrete(input)?, + "bound" => out.bound = Some(parse_bound(input)?), } } #[cfg(feature = "serde-compat")] impl_parse! { - SerdeStructAttr(input, out) { + Serde(input, out) { "rename" => out.0.rename = Some(parse_assign_str(input)?), - "rename_all" => out.0.rename_all = Some(parse_assign_str(input).and_then(Inflection::try_from)?), + "rename_all" => out.0.rename_all = Some(parse_assign_inflection(input)?), "tag" => out.0.tag = Some(parse_assign_str(input)?), + "bound" => out.0.bound = Some(parse_bound(input)?), // parse #[serde(default)] to not emit a warning "deny_unknown_fields" | "default" => { use syn::Token; diff --git a/macros/src/attr/variant.rs b/macros/src/attr/variant.rs index c6db337c2..52daedad6 100644 --- a/macros/src/attr/variant.rs +++ b/macros/src/attr/variant.rs @@ -1,6 +1,6 @@ -use syn::{Attribute, Ident, Result}; +use syn::{Attribute, Fields, Ident, Result, Variant}; -use super::EnumAttr; +use super::{Attr, Serde}; use crate::{ attr::{parse_assign_inflection, parse_assign_str, Inflection}, utils::parse_attrs, @@ -15,38 +15,40 @@ pub struct VariantAttr { pub untagged: bool, } -#[cfg(feature = "serde-compat")] -#[derive(Default)] -pub struct SerdeVariantAttr(VariantAttr); - impl VariantAttr { - pub fn new(attrs: &[Attribute], enum_attr: &EnumAttr) -> Result { - let mut result = Self::default(); - parse_attrs(attrs)?.for_each(|a| result.merge(a)); - result.rename_all = result.rename_all.or(enum_attr.rename_all_fields); + pub fn from_attrs(attrs: &[Attribute]) -> Result { + let mut result = parse_attrs::(attrs)?; #[cfg(feature = "serde-compat")] if !result.skip { - crate::utils::parse_serde_attrs::(attrs) - .for_each(|a| result.merge(a.0)); + let serde_attr = crate::utils::parse_serde_attrs::(attrs); + result = result.merge(serde_attr.0); } Ok(result) } +} + +impl Attr for VariantAttr { + type Item = Variant; + + fn merge(self, other: Self) -> Self { + Self { + rename: self.rename.or(other.rename), + rename_all: self.rename_all.or(other.rename_all), + inline: self.inline || other.inline, + skip: self.skip || other.skip, + untagged: self.untagged || other.untagged, + } + } + + fn assert_validity(&self, item: &Self::Item) -> Result<()> { + if !matches!(item.fields, Fields::Named(_)) && self.rename_all.is_some() { + syn_err_spanned!( + item; + "`rename_all` is not applicable to unit or tuple variants" + ) + } - fn merge( - &mut self, - VariantAttr { - rename, - rename_all, - inline, - skip, - untagged, - }: VariantAttr, - ) { - self.rename = self.rename.take().or(rename); - self.rename_all = self.rename_all.take().or(rename_all); - self.inline = self.inline || inline; - self.skip = self.skip || skip; - self.untagged = self.untagged || untagged; + Ok(()) } } @@ -62,7 +64,7 @@ impl_parse! { #[cfg(feature = "serde-compat")] impl_parse! { - SerdeVariantAttr(input, out) { + Serde(input, out) { "rename" => out.0.rename = Some(parse_assign_str(input)?), "rename_all" => out.0.rename_all = Some(parse_assign_inflection(input)?), "skip" => out.0.skip = true, diff --git a/macros/src/deps.rs b/macros/src/deps.rs index da02a80fd..e38c96c5a 100644 --- a/macros/src/deps.rs +++ b/macros/src/deps.rs @@ -1,36 +1,104 @@ +use std::{collections::HashSet, rc::Rc}; + use proc_macro2::TokenStream; use quote::{quote, ToTokens}; -use syn::Type; +use syn::{Path, Type}; + +pub struct Dependencies { + crate_rename: Rc, + dependencies: HashSet, + types: HashSet>, +} -#[derive(Default)] -pub struct Dependencies(Vec); +#[derive(Hash, Eq, PartialEq)] +enum Dependency { + // A dependency on all dependencies of `ty`. + // This does not include a dependency on `ty` itself - only its dependencies! + Transitive { + crate_rename: Rc, + ty: Rc, + }, + // A dependency on all type parameters of `ty`, as returned by `TS::generics()`. + // This does not include a dependency on `ty` itself. + Generics { + crate_rename: Rc, + ty: Rc, + }, + Type(Rc), +} impl Dependencies { + pub fn new(crate_rename: Path) -> Self { + Self { + dependencies: HashSet::new(), + crate_rename: Rc::new(crate_rename), + types: HashSet::new(), + } + } + + pub fn used_types(&self) -> impl Iterator { + self.types.iter().map(Rc::as_ref) + } + /// Adds all dependencies from the given type pub fn append_from(&mut self, ty: &Type) { - self.0 - .push(quote![.extend(<#ty as ts_rs::TS>::dependency_types())]); + let ty = self.push_type(ty); + self.dependencies.insert(Dependency::Transitive { + crate_rename: self.crate_rename.clone(), + ty: ty.clone(), + }); } /// Adds the given type. pub fn push(&mut self, ty: &Type) { - self.0.push(quote![.push::<#ty>()]); - self.0.push(quote![ - .extend(<#ty as ts_rs::TS>::generics()) - ]); + let ty = self.push_type(ty); + self.dependencies.insert(Dependency::Type(ty.clone())); + self.dependencies.insert(Dependency::Generics { + crate_rename: self.crate_rename.clone(), + ty: ty.clone(), + }); } pub fn append(&mut self, other: Dependencies) { - self.0.push(quote![.extend(#other)]); + self.dependencies.extend(other.dependencies); + self.types.extend(other.types); + } + + fn push_type(&mut self, ty: &Type) -> Rc { + // this can be replaces with `get_or_insert_owned` once #60896 is stabilized + match self.types.get(ty) { + None => { + let ty = Rc::new(ty.clone()); + self.types.insert(ty.clone()); + ty + } + Some(ty) => ty.clone(), + } } } impl ToTokens for Dependencies { fn to_tokens(&self, tokens: &mut TokenStream) { - let lines = &self.0; + let crate_rename = &self.crate_rename; + let lines = self.dependencies.iter(); + tokens.extend(quote![{ - use ts_rs::typelist::TypeList; + use #crate_rename::typelist::TypeList; ()#(#lines)* - }]) + }]); + } +} + +impl ToTokens for Dependency { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(match self { + Dependency::Transitive { crate_rename, ty } => { + quote![.extend(<#ty as #crate_rename::TS>::dependency_types())] + } + Dependency::Generics { crate_rename, ty } => { + quote![.extend(<#ty as #crate_rename::TS>::generics())] + } + Dependency::Type(ty) => quote![.push::<#ty>()], + }); } } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4db88a668..e13447897 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,11 +1,14 @@ #![macro_use] #![deny(unused)] +use std::collections::{HashMap, HashSet}; + use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; use syn::{ - parse_quote, spanned::Spanned, ConstParam, GenericParam, Generics, Item, LifetimeParam, Result, - TypeParam, WhereClause, + parse_quote, spanned::Spanned, ConstParam, GenericParam, Generics, Item, LifetimeParam, Path, + Result, Type, TypeArray, TypeParam, TypeParen, TypePath, TypeReference, TypeSlice, TypeTuple, + WhereClause, WherePredicate, }; use crate::{deps::Dependencies, utils::format_generics}; @@ -17,12 +20,14 @@ mod deps; mod types; struct DerivedTS { - generics: Generics, + crate_rename: Path, ts_name: String, docs: String, inline: TokenStream, inline_flattened: Option, dependencies: Dependencies, + concrete: HashMap, + bound: Option>, export: bool, export_to: Option, @@ -44,11 +49,8 @@ impl DerivedTS { }; quote! { - fn output_path() -> Option { - let path = std::env::var("TS_RS_EXPORT_DIR"); - let path = path.as_deref().unwrap_or("./bindings"); - - Some(std::path::Path::new(path).join(#path)) + fn output_path() -> Option<&'static std::path::Path> { + Some(std::path::Path::new(#path)) } } }; @@ -58,14 +60,22 @@ impl DerivedTS { docs => Some(quote!(const DOCS: Option<&'static str> = Some(#docs);)), }; + let crate_rename = self.crate_rename.clone(); + let ident = self.ts_name.clone(); - let impl_start = generate_impl_block_header(&rust_ty, &generics); - let assoc_type = generate_assoc_type(&rust_ty, &generics); - let name = self.generate_name_fn(); + let impl_start = generate_impl_block_header( + &crate_rename, + &rust_ty, + &generics, + self.bound.as_deref(), + &self.dependencies, + ); + let assoc_type = generate_assoc_type(&rust_ty, &crate_rename, &generics, &self.concrete); + let name = self.generate_name_fn(&generics); let inline = self.generate_inline_fn(); - let decl = self.generate_decl_fn(&rust_ty); + let decl = self.generate_decl_fn(&rust_ty, &generics); let dependencies = &self.dependencies; - let generics_fn = self.generate_generics_fn(); + let generics_fn = self.generate_generics_fn(&generics); quote! { #impl_start { @@ -83,7 +93,7 @@ impl DerivedTS { #output_path_fn #[allow(clippy::unused_unit)] - fn dependency_types() -> impl ts_rs::typelist::TypeList + fn dependency_types() -> impl #crate_rename::typelist::TypeList where Self: 'static, { @@ -97,13 +107,14 @@ impl DerivedTS { /// Returns an expression which evaluates to the TypeScript name of the type, including generic /// parameters. - fn name_with_generics(&self) -> TokenStream { + fn name_with_generics(&self, generics: &Generics) -> TokenStream { let name = &self.ts_name; - let mut generics_ts_names = self - .generics + let crate_rename = &self.crate_rename; + let mut generics_ts_names = generics .type_params() + .filter(|ty| !self.concrete.contains_key(&ty.ident)) .map(|ty| &ty.ident) - .map(|generic| quote!(<#generic as ts_rs::TS>::name())) + .map(|generic| quote!(<#generic as #crate_rename::TS>::name())) .peekable(); if generics_ts_names.peek().is_some() { @@ -128,8 +139,12 @@ impl DerivedTS { /// struct B; /// impl ts_rs::TS for B { /* .. */ } /// ``` - fn generate_generic_types(&self) -> TokenStream { - let generics = self.generics.type_params().map(|ty| ty.ident.clone()); + fn generate_generic_types(&self, generics: &Generics) -> TokenStream { + let crate_rename = &self.crate_rename; + let generics = generics + .type_params() + .filter(|ty| !self.concrete.contains_key(&ty.ident)) + .map(|ty| ty.ident.clone()); quote! { #( @@ -140,7 +155,7 @@ impl DerivedTS { write!(f, "{:?}", self) } } - impl TS for #generics { + impl #crate_rename::TS for #generics { type WithoutGenerics = #generics; fn name() -> String { stringify!(#generics).to_owned() } fn inline() -> String { panic!("{} cannot be inlined", Self::name()) } @@ -157,38 +172,45 @@ impl DerivedTS { "export_bindings_{}", rust_ty.to_string().to_lowercase().replace("r#", "") ); - let generic_params = generics.type_params().map(|_| quote! { ts_rs::Dummy }); - let ty = quote!(<#rust_ty<#(#generic_params),*> as ts_rs::TS>); + let crate_rename = &self.crate_rename; + let generic_params = generics + .type_params() + .map(|ty| match self.concrete.get(&ty.ident) { + None => quote! { #crate_rename::Dummy }, + Some(ty) => quote! { #ty }, + }); + let ty = quote!(<#rust_ty<#(#generic_params),*> as #crate_rename::TS>); quote! { #[cfg(test)] #[test] #[ignore = "To export your ts-rs bindings, run `cargo test export_bindings_ -- --ignored`"] fn #test_fn() { - #ty::export().expect("could not export type"); + #ty::export_all().expect("could not export type"); } } } - fn generate_generics_fn(&self) -> TokenStream { - let generics = self - .generics + fn generate_generics_fn(&self, generics: &Generics) -> TokenStream { + let crate_rename = &self.crate_rename; + let generics = generics .type_params() - .map(|TypeParam { ident, .. }| quote![.push::<#ident>().extend(<#ident as ts_rs::TS>::generics())]); + .filter(|ty| !self.concrete.contains_key(&ty.ident)) + .map(|TypeParam { ident, .. }| quote![.push::<#ident>().extend(<#ident as #crate_rename::TS>::generics())]); quote! { #[allow(clippy::unused_unit)] - fn generics() -> impl ts_rs::typelist::TypeList + fn generics() -> impl #crate_rename::typelist::TypeList where Self: 'static, { - use ts_rs::typelist::TypeList; + use #crate_rename::typelist::TypeList; ()#(#generics)* } } } - fn generate_name_fn(&self) -> TokenStream { - let name = self.name_with_generics(); + fn generate_name_fn(&self, generics: &Generics) -> TokenStream { + let name = self.name_with_generics(generics); quote! { fn name() -> String { #name @@ -230,23 +252,32 @@ impl DerivedTS { /// `decl_concrete()` is simple, and simply defers to `inline()`. /// For `decl()`, however, we need to change out the generic parameters of the type, replacing /// them with the dummy types generated by `generate_generic_types()`. - fn generate_decl_fn(&mut self, rust_ty: &Ident) -> TokenStream { + fn generate_decl_fn(&mut self, rust_ty: &Ident, generics: &Generics) -> TokenStream { let name = &self.ts_name; - let generic_types = self.generate_generic_types(); - let ts_generics = format_generics(&mut self.dependencies, &self.generics); + let crate_rename = &self.crate_rename; + let generic_types = self.generate_generic_types(generics); + let ts_generics = format_generics( + &mut self.dependencies, + crate_rename, + generics, + &self.concrete, + ); use GenericParam as G; // These are the generic parameters we'll be using. - let generic_idents = self.generics.params.iter().filter_map(|p| match p { + let generic_idents = generics.params.iter().filter_map(|p| match p { G::Lifetime(_) => None, - // Since we named our dummy types the same as the generic parameters, we can just keep - // the identifier of the generic parameter - its name is shadowed by the dummy struct. - // + G::Type(TypeParam { ident, .. }) => match self.concrete.get(ident) { + // Since we named our dummy types the same as the generic parameters, we can just keep + // the identifier of the generic parameter - its name is shadowed by the dummy struct. + None => Some(quote!(#ident)), + // If the type parameter is concrete, we use the type the user provided using + // `#[ts(concrete)]` + Some(concrete) => Some(quote!(#concrete)), + }, // We keep const parameters as they are, since there's no sensible default value we can // use instead. This might be something to change in the future. - G::Type(TypeParam { ident, .. }) | G::Const(ConstParam { ident, .. }) => { - Some(quote!(#ident)) - } + G::Const(ConstParam { ident, .. }) => Some(quote!(#ident)), }); quote! { fn decl_concrete() -> String { @@ -254,7 +285,7 @@ impl DerivedTS { } fn decl() -> String { #generic_types - let inline = <#rust_ty<#(#generic_idents,)*> as ts_rs::TS>::inline(); + let inline = <#rust_ty<#(#generic_idents,)*> as #crate_rename::TS>::inline(); let generics = #ts_generics; format!("type {}{generics} = {inline};", #name) } @@ -262,31 +293,37 @@ impl DerivedTS { } } -fn generate_assoc_type(rust_ty: &Ident, generics: &Generics) -> TokenStream { +fn generate_assoc_type( + rust_ty: &Ident, + crate_rename: &Path, + generics: &Generics, + concrete: &HashMap, +) -> TokenStream { use GenericParam as G; - let generics_params = generics - .params - .iter() - .map(|x| match x { - G::Type(_) => quote! { ts_rs::Dummy }, - G::Const(ConstParam { ident, .. }) => quote! { #ident }, - G::Lifetime(LifetimeParam { lifetime, .. }) => quote! { #lifetime }, - }) - .collect::>(); - - if generics_params.is_empty() { - quote! { type WithoutGenerics = #rust_ty; } - } else { - quote! { type WithoutGenerics = #rust_ty<#(#generics_params),*>; } - } + let generics_params = generics.params.iter().map(|x| match x { + G::Type(ty) => match concrete.get(&ty.ident) { + None => quote! { #crate_rename::Dummy }, + Some(ty) => quote! { #ty }, + }, + G::Const(ConstParam { ident, .. }) => quote! { #ident }, + G::Lifetime(LifetimeParam { lifetime, .. }) => quote! { #lifetime }, + }); + + quote! { type WithoutGenerics = #rust_ty<#(#generics_params),*>; } } // generate start of the `impl TS for #ty` block, up to (excluding) the open brace -fn generate_impl_block_header(ty: &Ident, generics: &Generics) -> TokenStream { +fn generate_impl_block_header( + crate_rename: &Path, + ty: &Ident, + generics: &Generics, + bounds: Option<&[WherePredicate]>, + dependencies: &Dependencies, +) -> TokenStream { use GenericParam as G; - let bounds = generics.params.iter().map(|param| match param { + let params = generics.params.iter().map(|param| match param { G::Type(TypeParam { ident, colon_token, @@ -312,24 +349,77 @@ fn generate_impl_block_header(ty: &Ident, generics: &Generics) -> TokenStream { G::Lifetime(LifetimeParam { lifetime, .. }) => quote!(#lifetime), }); - let where_bound = add_ts_to_where_clause(generics); - quote!(impl <#(#bounds),*> ts_rs::TS for #ty <#(#type_args),*> #where_bound) + let where_bound = match bounds { + Some(bounds) => quote! { where #(#bounds),* }, + None => { + let bounds = generate_where_clause(crate_rename, generics, dependencies); + quote! { #bounds } + } + }; + + quote!(impl <#(#params),*> #crate_rename::TS for #ty <#(#type_args),*> #where_bound) } -fn add_ts_to_where_clause(generics: &Generics) -> Option { - let generic_types = generics - .type_params() - .map(|ty| ty.ident.clone()) - .collect::>(); - if generic_types.is_empty() { - return generics.where_clause.clone(); +fn generate_where_clause( + crate_rename: &Path, + generics: &Generics, + dependencies: &Dependencies, +) -> WhereClause { + let used_types = { + let is_type_param = |id: &Ident| generics.type_params().any(|p| &p.ident == id); + + let mut used_types = HashSet::new(); + for ty in dependencies.used_types() { + used_type_params(&mut used_types, ty, is_type_param); + } + used_types.into_iter() + }; + + let existing = generics.where_clause.iter().flat_map(|w| &w.predicates); + parse_quote! { + where #(#existing,)* #(#used_types: #crate_rename::TS),* } - match generics.where_clause { - None => Some(parse_quote! { where #( #generic_types : ts_rs::TS ),* }), - Some(ref w) => { - let bounds = w.predicates.iter(); - Some(parse_quote! { where #(#bounds,)* #( #generic_types : ts_rs::TS ),* }) +} + +// Extracts all type parameters which are used within the given type. +// Associated types of a type parameter are extracted as well. +// Note: This will not extract `I` from `I::Item`, but just `I::Item`! +fn used_type_params<'ty, 'out>( + out: &'out mut HashSet<&'ty Type>, + ty: &'ty Type, + is_type_param: impl Fn(&'ty Ident) -> bool + Copy + 'out, +) { + use syn::{ + AngleBracketedGenericArguments as GenericArgs, GenericArgument as G, PathArguments as P, + }; + + match ty { + Type::Array(TypeArray { elem, .. }) + | Type::Paren(TypeParen { elem, .. }) + | Type::Reference(TypeReference { elem, .. }) + | Type::Slice(TypeSlice { elem, .. }) => used_type_params(out, elem, is_type_param), + Type::Tuple(TypeTuple { elems, .. }) => elems + .iter() + .for_each(|elem| used_type_params(out, elem, is_type_param)), + Type::Path(TypePath { qself: None, path }) => { + let first = path.segments.first().unwrap(); + if is_type_param(&first.ident) { + // The type is either a generic parameter (e.g `T`), or an associated type of that + // generic parameter (e.g `I::Item`). Either way, we return it. + out.insert(ty); + return; + } + + let last = path.segments.last().unwrap(); + if let P::AngleBracketed(GenericArgs { ref args, .. }) = last.arguments { + for generic in args { + if let G::Type(ty) = generic { + used_type_params(out, ty, is_type_param); + } + } + } } + _ => (), } } diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs index 41556b41c..6dcc0f4e7 100644 --- a/macros/src/types/enum.rs +++ b/macros/src/types/enum.rs @@ -1,52 +1,65 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Fields, Generics, ItemEnum, Variant}; +use syn::{Fields, ItemEnum, Variant}; use crate::{ - attr::{EnumAttr, FieldAttr, StructAttr, Tagged, VariantAttr}, + attr::{Attr, EnumAttr, FieldAttr, StructAttr, Tagged, VariantAttr}, deps::Dependencies, - types, DerivedTS, + types::{self, type_as, type_override}, + DerivedTS, }; pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { let enum_attr: EnumAttr = EnumAttr::from_attrs(&s.attrs)?; + enum_attr.assert_validity(s)?; + + let crate_rename = enum_attr.crate_rename(); + let name = match &enum_attr.rename { Some(existing) => existing.clone(), None => s.ident.to_string(), }; + if let Some(attr_type_override) = &enum_attr.type_override { + return type_override::type_override_enum(&enum_attr, &name, attr_type_override); + } + if let Some(attr_type_as) = &enum_attr.type_as { + return type_as::type_as_enum(&enum_attr, &name, attr_type_as); + } + if s.variants.is_empty() { - return Ok(empty_enum(name, enum_attr, s.generics.clone())); + return Ok(empty_enum(name, enum_attr)); } if s.variants.is_empty() { return Ok(DerivedTS { - generics: s.generics.clone(), + crate_rename: crate_rename.clone(), ts_name: name, docs: enum_attr.docs, inline: quote!("never".to_owned()), inline_flattened: None, - dependencies: Dependencies::default(), + dependencies: Dependencies::new(crate_rename), export: enum_attr.export, export_to: enum_attr.export_to, + concrete: enum_attr.concrete, + bound: enum_attr.bound, }); } let mut formatted_variants = Vec::new(); - let mut dependencies = Dependencies::default(); + let mut dependencies = Dependencies::new(crate_rename.clone()); for variant in &s.variants { format_variant( &mut formatted_variants, &mut dependencies, &enum_attr, variant, - &s.generics, )?; } Ok(DerivedTS { - generics: s.generics.clone(), + crate_rename, inline: quote!([#(#formatted_variants),*].join(" | ")), inline_flattened: Some(quote!( format!("({})", [#(#formatted_variants),*].join(" | ")) @@ -56,6 +69,8 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { export: enum_attr.export, export_to: enum_attr.export_to, ts_name: name, + concrete: enum_attr.concrete, + bound: enum_attr.bound, }) } @@ -64,9 +79,15 @@ fn format_variant( dependencies: &mut Dependencies, enum_attr: &EnumAttr, variant: &Variant, - generics: &Generics, ) -> syn::Result<()> { - let variant_attr = VariantAttr::new(&variant.attrs, enum_attr)?; + let crate_rename = enum_attr.crate_rename(); + + // If `variant.fields` is not a `Fields::Named(_)` the `rename_all_fields` + // attribute must be ignored to prevent a `rename_all` from getting to + // the newtype, tuple or unit formatting, which would cause an error + let variant_attr = VariantAttr::from_attrs(&variant.attrs)?; + + variant_attr.assert_validity(variant)?; if variant_attr.skip { return Ok(()); @@ -79,12 +100,12 @@ fn format_variant( (None, Some(rn)) => rn.apply(&variant.ident.to_string()), }; + let struct_attr = StructAttr::from_variant(enum_attr, &variant_attr, &variant.fields); let variant_type = types::type_def( - &StructAttr::from(variant_attr), + &struct_attr, // since we are generating the variant as a struct, it doesn't have a name &format_ident!("_"), &variant.fields, - generics, )?; let variant_dependencies = variant_type.dependencies; let inline_type = variant_type.inline; @@ -94,8 +115,12 @@ fn format_variant( (false, Tagged::Externally) => match &variant.fields { Fields::Unit => quote!(format!("\"{}\"", #name)), Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => { - let FieldAttr { skip, .. } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?; - if skip { + let field = &unnamed.unnamed[0]; + let field_attr = FieldAttr::from_attrs(&field.attrs)?; + + field_attr.assert_validity(field)?; + + if field_attr.skip { quote!(format!("\"{}\"", #name)) } else { quote!(format!("{{ \"{}\": {} }}", #name, #inline_type)) @@ -105,25 +130,32 @@ fn format_variant( }, (false, Tagged::Adjacently { tag, content }) => match &variant.fields { Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => { + let field = &unnamed.unnamed[0]; + let field_attr = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?; + + field_attr.assert_validity(field)?; + let FieldAttr { type_as, type_override, skip, .. - } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?; + } = field_attr; if skip { quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name)) } else { let ty = match (type_override, type_as) { - (Some(_), Some(_)) => syn_err_spanned!(variant; "`type` is not compatible with `as`"), + (Some(_), Some(_)) => { + unreachable!("This has been handled by assert_validity") + } (Some(type_override), None) => quote! { #type_override }, (None, Some(type_as)) => { - quote!(<#type_as as ts_rs::TS>::name()) + quote!(<#type_as as #crate_rename::TS>::name()) } (None, None) => { let ty = &unnamed.unnamed[0].ty; - quote!(<#ty as ts_rs::TS>::name()) + quote!(<#ty as #crate_rename::TS>::name()) } }; @@ -154,25 +186,32 @@ fn format_variant( }, None => match &variant.fields { Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => { + let field = &unnamed.unnamed[0]; + let field_attr = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?; + + field_attr.assert_validity(field)?; + let FieldAttr { type_as, skip, type_override, .. - } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?; + } = field_attr; if skip { quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name)) } else { let ty = match (type_override, type_as) { - (Some(_), Some(_)) => syn_err_spanned!(variant; "`type` is not compatible with `as`"), + (Some(_), Some(_)) => { + unreachable!("This has been handled by assert_validity") + } (Some(type_override), None) => quote! { #type_override }, (None, Some(type_as)) => { - quote!(<#type_as as ts_rs::TS>::name()) + quote!(<#type_as as #crate_rename::TS>::name()) } (None, None) => { let ty = &unnamed.unnamed[0].ty; - quote!(<#ty as ts_rs::TS>::name()) + quote!(<#ty as #crate_rename::TS>::name()) } }; @@ -193,16 +232,19 @@ fn format_variant( } // bindings for an empty enum (`never` in TS) -fn empty_enum(name: impl Into, enum_attr: EnumAttr, generics: Generics) -> DerivedTS { +fn empty_enum(name: impl Into, enum_attr: EnumAttr) -> DerivedTS { let name = name.into(); + let crate_rename = enum_attr.crate_rename(); DerivedTS { - generics, + crate_rename: crate_rename.clone(), inline: quote!("never".to_owned()), docs: enum_attr.docs, inline_flattened: None, - dependencies: Dependencies::default(), + dependencies: Dependencies::new(crate_rename), export: enum_attr.export, export_to: enum_attr.export_to, ts_name: name, + concrete: enum_attr.concrete, + bound: enum_attr.bound, } } diff --git a/macros/src/types/mod.rs b/macros/src/types/mod.rs index 1bdd276d5..caebefc00 100644 --- a/macros/src/types/mod.rs +++ b/macros/src/types/mod.rs @@ -1,11 +1,17 @@ -use syn::{Fields, Generics, Ident, ItemStruct, Result}; +use syn::{Fields, Ident, ItemStruct, Result}; -use crate::{attr::StructAttr, utils::to_ts_ident, DerivedTS}; +use crate::{ + attr::{Attr, StructAttr}, + utils::to_ts_ident, + DerivedTS, +}; mod r#enum; mod named; mod newtype; mod tuple; +mod type_as; +mod type_override; mod unit; pub(crate) use r#enum::r#enum_def; @@ -13,26 +19,30 @@ pub(crate) use r#enum::r#enum_def; pub(crate) fn struct_def(s: &ItemStruct) -> Result { let attr = StructAttr::from_attrs(&s.attrs)?; - type_def(&attr, &s.ident, &s.fields, &s.generics) + type_def(&attr, &s.ident, &s.fields) } -fn type_def( - attr: &StructAttr, - ident: &Ident, - fields: &Fields, - generics: &Generics, -) -> Result { +fn type_def(attr: &StructAttr, ident: &Ident, fields: &Fields) -> Result { + attr.assert_validity(fields)?; + let name = attr.rename.clone().unwrap_or_else(|| to_ts_ident(ident)); + if let Some(attr_type_override) = &attr.type_override { + return type_override::type_override_struct(attr, &name, attr_type_override); + } + if let Some(attr_type_as) = &attr.type_as { + return type_as::type_as_struct(attr, &name, attr_type_as); + } + match fields { Fields::Named(named) => match named.named.len() { - 0 => unit::empty_object(attr, &name, generics.clone()), - _ => named::named(attr, &name, named, generics), + 0 => unit::empty_object(attr, &name), + _ => named::named(attr, &name, named), }, Fields::Unnamed(unnamed) => match unnamed.unnamed.len() { - 0 => unit::empty_array(attr, &name, generics.clone()), - 1 => newtype::newtype(attr, &name, unnamed, generics), - _ => tuple::tuple(attr, &name, unnamed, generics), + 0 => unit::empty_array(attr, &name), + 1 => newtype::newtype(attr, &name, unnamed), + _ => tuple::tuple(attr, &name, unnamed), }, - Fields::Unit => unit::null(attr, &name, generics.clone()), + Fields::Unit => unit::null(attr, &name), } } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 8ba236bb2..3573c5bb6 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -1,23 +1,23 @@ use proc_macro2::TokenStream; -use quote::{quote, ToTokens}; -use syn::{spanned::Spanned, Field, FieldsNamed, GenericArgument, Generics, PathArguments, Result, Type}; +use quote::quote; +use syn::{ + spanned::Spanned, Field, FieldsNamed, GenericArgument, Path, PathArguments, Result, Type, +}; use crate::{ - attr::{FieldAttr, Inflection, Optional, StructAttr}, + attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, deps::Dependencies, utils::{raw_name_to_ts_field, to_ts_ident}, DerivedTS, }; -pub(crate) fn named( - attr: &StructAttr, - name: &str, - fields: &FieldsNamed, - generics: &Generics, -) -> Result { +pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { + let crate_rename = attr.crate_rename(); + let mut formatted_fields = Vec::new(); let mut flattened_fields = Vec::new(); - let mut dependencies = Dependencies::default(); + let mut dependencies = Dependencies::new(crate_rename.clone()); + if let Some(tag) = &attr.tag { let formatted = format!("{}: \"{}\",", tag, name); formatted_fields.push(quote! { @@ -27,12 +27,12 @@ pub(crate) fn named( for field in &fields.named { format_field( + &crate_rename, &mut formatted_fields, &mut flattened_fields, &mut dependencies, field, &attr.rename_all, - generics, )?; } @@ -47,19 +47,30 @@ pub(crate) fn named( (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), }; + let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) { + (0, 0) => quote!("{ }".to_owned()), + (_, 0) => quote!(format!("{{ {} }}", #fields)), + (0, _) => quote!(#flattened), + (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), + }; + Ok(DerivedTS { - generics: generics.clone(), + crate_rename, + // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it + // results in simpler type definitions. inline: quote!(#inline.replace(" } & { ", " ")), - inline_flattened: Some(quote!(format!("{{ {} }}", #fields))), + inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))), docs: attr.docs.clone(), dependencies, export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } -// build an expresion which expands to a string, representing a single field of a struct. +// build an expression which expands to a string, representing a single field of a struct. // // formatted_fields will contain all the fields that do not contain the flatten // attribute, in the format @@ -70,13 +81,17 @@ pub(crate) fn named( // but for enums is // ({ /* variant data */ } | { /* variant data */ }) fn format_field( + crate_rename: &Path, formatted_fields: &mut Vec, flattened_fields: &mut Vec, dependencies: &mut Dependencies, field: &Field, rename_all: &Option, - _generics: &Generics, ) -> Result<()> { + let field_attr = FieldAttr::from_attrs(&field.attrs)?; + + field_attr.assert_validity(field)?; + let FieldAttr { type_as, type_override, @@ -86,21 +101,16 @@ fn format_field( optional, flatten, docs, - } = FieldAttr::from_attrs(&field.attrs)?; + + #[cfg(feature = "serde-compat")] + using_serde_with: _, + } = field_attr; if skip { return Ok(()); } - if type_as.is_some() && type_override.is_some() { - syn_err_spanned!(field; "`type` is not compatible with `as`") - } - - let parsed_ty = if let Some(ref type_as) = type_as { - syn::parse_str::(&type_as.to_token_stream().to_string())? - } else { - field.ty.clone() - }; + let parsed_ty = type_as.as_ref().unwrap_or(&field.ty).clone(); let (ty, optional_annotation) = match optional { Optional { @@ -119,15 +129,7 @@ fn format_field( }; if flatten { - match (&type_as, &type_override, &rename, inline) { - (Some(_), _, _, _) => syn_err_spanned!(field; "`as` is not compatible with `flatten`"), - (_, Some(_), _, _) => syn_err_spanned!(field; "`type` is not compatible with `flatten`"), - (_, _, Some(_), _) => syn_err_spanned!(field; "`rename` is not compatible with `flatten`"), - (_, _, _, true) => syn_err_spanned!(field; "`inline` is not compatible with `flatten`"), - _ => {} - } - - flattened_fields.push(quote!(<#ty as ts_rs::TS>::inline_flattened())); + flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); dependencies.append_from(ty); return Ok(()); } @@ -135,12 +137,13 @@ fn format_field( let formatted_ty = type_override.map(|t| quote!(#t)).unwrap_or_else(|| { if inline { dependencies.append_from(ty); - quote!(<#ty as ts_rs::TS>::inline()) + quote!(<#ty as #crate_rename::TS>::inline()) } else { dependencies.push(ty); - quote!(<#ty as ts_rs::TS>::name()) + quote!(<#ty as #crate_rename::TS>::name()) } }); + let field_name = to_ts_ident(field.ident.as_ref().unwrap()); let name = match (rename, rename_all) { (Some(rn), _) => rn, @@ -178,7 +181,9 @@ fn extract_option_argument(ty: &Type) -> Result<&Type> { other => syn_err!(other.span(); "`Option` argument must be a type"), } } - other => syn_err!(other.span(); "`Option` type must have a single generic argument"), + other => { + syn_err!(other.span(); "`Option` type must have a single generic argument") + } } } other => syn_err!(other.span(); "`optional` can only be used on an Option type"), diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs index 24ce4d2ea..e993386fe 100644 --- a/macros/src/types/newtype.rs +++ b/macros/src/types/newtype.rs @@ -1,70 +1,51 @@ -use quote::{quote, ToTokens}; -use syn::{FieldsUnnamed, Generics, Result, Type}; +use quote::quote; +use syn::{FieldsUnnamed, Result}; use crate::{ - attr::{FieldAttr, StructAttr}, + attr::{Attr, ContainerAttr, FieldAttr, StructAttr}, deps::Dependencies, DerivedTS, }; -pub(crate) fn newtype( - attr: &StructAttr, - name: &str, - fields: &FieldsUnnamed, - generics: &Generics, -) -> Result { - if attr.rename_all.is_some() { - syn_err!("`rename_all` is not applicable to newtype structs"); - } - if attr.tag.is_some() { - syn_err!("`tag` is not applicable to newtype structs"); - } +pub(crate) fn newtype(attr: &StructAttr, name: &str, fields: &FieldsUnnamed) -> Result { let inner = fields.unnamed.first().unwrap(); + + let field_attr = FieldAttr::from_attrs(&inner.attrs)?; + field_attr.assert_validity(inner)?; + let FieldAttr { type_as, type_override, - rename: rename_inner, inline, skip, - optional, - flatten, docs: _, - } = FieldAttr::from_attrs(&inner.attrs)?; + .. + } = field_attr; - match (&rename_inner, skip, optional.optional, flatten) { - (Some(_), ..) => syn_err_spanned!(fields; "`rename` is not applicable to newtype fields"), - (_, true, ..) => return super::unit::null(attr, name, generics.clone()), - (_, _, true, ..) => syn_err_spanned!(fields; "`optional` is not applicable to newtype fields"), - (_, _, _, true) => syn_err_spanned!(fields; "`flatten` is not applicable to newtype fields"), - _ => {} - }; + let crate_rename = attr.crate_rename(); - if type_as.is_some() && type_override.is_some() { - syn_err_spanned!(fields; "`type` is not compatible with `as`") + if skip { + return super::unit::null(attr, name); } - let inner_ty = if let Some(ref type_as) = type_as { - syn::parse_str::(&type_as.to_token_stream().to_string())? - } else { - inner.ty.clone() - }; + let inner_ty = type_as.as_ref().unwrap_or(&inner.ty).clone(); - let mut dependencies = Dependencies::default(); + let mut dependencies = Dependencies::new(crate_rename.clone()); - match (type_override.is_none(), inline) { - (false, _) => (), - (true, true) => dependencies.append_from(&inner_ty), - (true, false) => dependencies.push(&inner_ty), + match (&type_override, inline) { + (Some(_), _) => (), + (None, true) => dependencies.append_from(&inner_ty), + (None, false) => dependencies.push(&inner_ty), }; let inline_def = match type_override { Some(ref o) => quote!(#o.to_owned()), - None if inline => quote!(<#inner_ty as ts_rs::TS>::inline()), - None => quote!(<#inner_ty as ts_rs::TS>::name()), + None if inline => quote!(<#inner_ty as #crate_rename::TS>::inline()), + None => quote!(<#inner_ty as #crate_rename::TS>::name()), }; Ok(DerivedTS { - generics: generics.clone(), + crate_rename, inline: inline_def, inline_flattened: None, docs: attr.docs.clone(), @@ -72,5 +53,7 @@ pub(crate) fn newtype( export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs index cfe536d68..9b41b8120 100644 --- a/macros/src/types/tuple.rs +++ b/macros/src/types/tuple.rs @@ -1,34 +1,28 @@ use proc_macro2::TokenStream; -use quote::{quote, ToTokens}; -use syn::{Field, FieldsUnnamed, Generics, Result, Type}; +use quote::quote; +use syn::{Field, FieldsUnnamed, Path, Result}; use crate::{ - attr::{FieldAttr, StructAttr}, + attr::{Attr, ContainerAttr, FieldAttr, StructAttr}, deps::Dependencies, DerivedTS, }; -pub(crate) fn tuple( - attr: &StructAttr, - name: &str, - fields: &FieldsUnnamed, - generics: &Generics, -) -> Result { - if attr.rename_all.is_some() { - syn_err!("`rename_all` is not applicable to tuple structs"); - } - if attr.tag.is_some() { - syn_err!("`tag` is not applicable to tuple structs"); - } - +pub(crate) fn tuple(attr: &StructAttr, name: &str, fields: &FieldsUnnamed) -> Result { + let crate_rename = attr.crate_rename(); let mut formatted_fields = Vec::new(); - let mut dependencies = Dependencies::default(); + let mut dependencies = Dependencies::new(crate_rename.clone()); for field in &fields.unnamed { - format_field(&mut formatted_fields, &mut dependencies, field, generics)?; + format_field( + &crate_rename, + &mut formatted_fields, + &mut dependencies, + field, + )?; } Ok(DerivedTS { - generics: generics.clone(), + crate_rename, inline: quote! { format!( "[{}]", @@ -41,56 +35,44 @@ pub(crate) fn tuple( export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } fn format_field( + crate_rename: &Path, formatted_fields: &mut Vec, dependencies: &mut Dependencies, field: &Field, - _generics: &Generics, ) -> Result<()> { + let field_attr = FieldAttr::from_attrs(&field.attrs)?; + field_attr.assert_validity(field)?; + let FieldAttr { type_as, type_override, - rename, + rename: _, inline, skip, - optional, - flatten, + optional: _, + flatten: _, docs: _, - } = FieldAttr::from_attrs(&field.attrs)?; + + #[cfg(feature = "serde-compat")] + using_serde_with: _, + } = field_attr; if skip { return Ok(()); } - let ty = if let Some(ref type_as) = type_as { - syn::parse_str::(&type_as.to_token_stream().to_string())? - } else { - field.ty.clone() - }; - - if type_as.is_some() && type_override.is_some() { - syn_err_spanned!(field; "`type` is not compatible with `as`") - } - - if rename.is_some() { - syn_err_spanned!(field; "`rename` is not applicable to tuple structs") - } - - if optional.optional { - syn_err_spanned!(field; "`optional` is not applicable to tuple fields") - } - - if flatten { - syn_err_spanned!(field; "`flatten` is not applicable to tuple fields") - } + let ty = type_as.as_ref().unwrap_or(&field.ty).clone(); formatted_fields.push(match type_override { Some(ref o) => quote!(#o.to_owned()), - None if inline => quote!(<#ty as ts_rs::TS>::inline()), - None => quote!(<#ty as ts_rs::TS>::name()), + None if inline => quote!(<#ty as #crate_rename::TS>::inline()), + None => quote!(<#ty as #crate_rename::TS>::name()), }); match (inline, type_override) { diff --git a/macros/src/types/type_as.rs b/macros/src/types/type_as.rs new file mode 100644 index 000000000..9a76eac24 --- /dev/null +++ b/macros/src/types/type_as.rs @@ -0,0 +1,42 @@ +use quote::quote; +use syn::{Result, Type}; + +use crate::{ + attr::{ContainerAttr, EnumAttr, StructAttr}, + deps::Dependencies, + DerivedTS, +}; + +pub(crate) fn type_as_struct(attr: &StructAttr, name: &str, type_as: &Type) -> Result { + let crate_rename = attr.crate_rename(); + + Ok(DerivedTS { + crate_rename: crate_rename.clone(), + inline: quote!(#type_as::inline()), + inline_flattened: None, + docs: attr.docs.clone(), + dependencies: Dependencies::new(crate_rename), + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} + +pub(crate) fn type_as_enum(attr: &EnumAttr, name: &str, type_as: &Type) -> Result { + let crate_rename = attr.crate_rename(); + + Ok(DerivedTS { + crate_rename: crate_rename.clone(), + inline: quote!(#type_as::inline()), + inline_flattened: None, + docs: attr.docs.clone(), + dependencies: Dependencies::new(crate_rename), + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} diff --git a/macros/src/types/type_override.rs b/macros/src/types/type_override.rs new file mode 100644 index 000000000..1f9c06f91 --- /dev/null +++ b/macros/src/types/type_override.rs @@ -0,0 +1,50 @@ +use quote::quote; +use syn::Result; + +use crate::{ + attr::{ContainerAttr, EnumAttr, StructAttr}, + deps::Dependencies, + DerivedTS, +}; + +pub(crate) fn type_override_struct( + attr: &StructAttr, + name: &str, + type_override: &str, +) -> Result { + let crate_rename = attr.crate_rename(); + + Ok(DerivedTS { + crate_rename: crate_rename.clone(), + inline: quote!(#type_override.to_owned()), + inline_flattened: None, + docs: attr.docs.clone(), + dependencies: Dependencies::new(crate_rename), + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} + +pub(crate) fn type_override_enum( + attr: &EnumAttr, + name: &str, + type_override: &str, +) -> Result { + let crate_rename = attr.crate_rename(); + + Ok(DerivedTS { + crate_rename: crate_rename.clone(), + inline: quote!(#type_override.to_owned()), + inline_flattened: None, + docs: attr.docs.clone(), + dependencies: Dependencies::new(crate_rename), + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} diff --git a/macros/src/types/unit.rs b/macros/src/types/unit.rs index 5d50a5cf8..1a379545a 100644 --- a/macros/src/types/unit.rs +++ b/macros/src/types/unit.rs @@ -1,50 +1,63 @@ use quote::quote; -use syn::{Generics, Result}; +use syn::Result; -use crate::{attr::StructAttr, deps::Dependencies, DerivedTS}; +use crate::{ + attr::{ContainerAttr, StructAttr}, + deps::Dependencies, + DerivedTS, +}; -pub(crate) fn empty_object(attr: &StructAttr, name: &str, generics: Generics) -> Result { +pub(crate) fn empty_object(attr: &StructAttr, name: &str) -> Result { check_attributes(attr)?; + let crate_rename = attr.crate_rename(); Ok(DerivedTS { - generics: generics.clone(), + crate_rename: crate_rename.clone(), inline: quote!("Record".to_owned()), inline_flattened: None, docs: attr.docs.clone(), - dependencies: Dependencies::default(), + dependencies: Dependencies::new(crate_rename), export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } -pub(crate) fn empty_array(attr: &StructAttr, name: &str, generics: Generics) -> Result { +pub(crate) fn empty_array(attr: &StructAttr, name: &str) -> Result { check_attributes(attr)?; + let crate_rename = attr.crate_rename(); Ok(DerivedTS { - generics: generics.clone(), + crate_rename: crate_rename.clone(), inline: quote!("never[]".to_owned()), inline_flattened: None, docs: attr.docs.clone(), - dependencies: Dependencies::default(), + dependencies: Dependencies::new(crate_rename), export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } -pub(crate) fn null(attr: &StructAttr, name: &str, generics: Generics) -> Result { +pub(crate) fn null(attr: &StructAttr, name: &str) -> Result { check_attributes(attr)?; + let crate_rename = attr.crate_rename(); Ok(DerivedTS { - generics, + crate_rename: crate_rename.clone(), inline: quote!("null".to_owned()), inline_flattened: None, docs: attr.docs.clone(), - dependencies: Dependencies::default(), + dependencies: Dependencies::new(crate_rename), export: attr.export, export_to: attr.export_to.clone(), ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), }) } diff --git a/macros/src/utils.rs b/macros/src/utils.rs index fb6006153..51511d3dd 100644 --- a/macros/src/utils.rs +++ b/macros/src/utils.rs @@ -1,11 +1,13 @@ -use std::convert::TryFrom; +use std::collections::HashMap; use proc_macro2::{Ident, TokenStream}; use quote::quote; use syn::{ - spanned::Spanned, Attribute, Error, Expr, ExprLit, GenericParam, Generics, Lit, Meta, Result, + spanned::Spanned, Attribute, Error, Expr, ExprLit, GenericParam, Generics, Lit, Meta, Path, + Result, Type, }; +use super::attr::{Attr, Serde}; use crate::deps::Dependencies; macro_rules! syn_err { @@ -24,16 +26,16 @@ macro_rules! syn_err_spanned { } macro_rules! impl_parse { - ($i:ident ($input:ident, $out:ident) { $($k:pat => $e:expr),* $(,)? }) => { - impl std::convert::TryFrom<&syn::Attribute> for $i { + ($i:ident $(<$inner: ident>)? ($input:ident, $out:ident) { $($k:pat => $e:expr),* $(,)? }) => { + impl std::convert::TryFrom<&syn::Attribute> for $i $(<$inner>)? { type Error = syn::Error; fn try_from(attr: &syn::Attribute) -> syn::Result { attr.parse_args() } } - impl syn::parse::Parse for $i { + impl syn::parse::Parse for $i $(<$inner>)? { fn parse($input: syn::parse::ParseStream) -> syn::Result { - let mut $out = $i::default(); + let mut $out = Self::default(); loop { let span = $input.span(); let key: Ident = $input.call(syn::ext::IdentExt::parse_any)?; @@ -95,28 +97,33 @@ pub fn raw_name_to_ts_field(value: String) -> String { } /// Parse all `#[ts(..)]` attributes from the given slice. -pub fn parse_attrs<'a, A>(attrs: &'a [Attribute]) -> Result> +pub fn parse_attrs<'a, A>(attrs: &'a [Attribute]) -> Result where - A: TryFrom<&'a Attribute, Error = Error>, + A: TryFrom<&'a Attribute, Error = Error> + Attr, { Ok(attrs .iter() .filter(|a| a.path().is_ident("ts")) .map(A::try_from) .collect::>>()? - .into_iter()) + .into_iter() + .fold(A::default(), |acc, cur| acc.merge(cur))) } /// Parse all `#[serde(..)]` attributes from the given slice. #[cfg(feature = "serde-compat")] #[allow(unused)] -pub fn parse_serde_attrs<'a, A: TryFrom<&'a Attribute, Error = Error>>( - attrs: &'a [Attribute], -) -> impl Iterator { +pub fn parse_serde_attrs<'a, A>(attrs: &'a [Attribute]) -> Serde +where + A: Attr, + Serde: TryFrom<&'a Attribute, Error = Error>, +{ + use crate::attr::Serde; + attrs .iter() .filter(|a| a.path().is_ident("serde")) - .flat_map(|attr| match A::try_from(attr) { + .flat_map(|attr| match Serde::::try_from(attr) { Ok(attr) => Some(attr), Err(_) => { #[cfg(not(feature = "no-serde-warnings"))] @@ -132,8 +139,7 @@ pub fn parse_serde_attrs<'a, A: TryFrom<&'a Attribute, Error = Error>>( None } }) - .collect::>() - .into_iter() + .fold(Serde::::default(), |acc, cur| acc.merge(cur)) } /// Return doc comments parsed and formatted as JSDoc. @@ -223,20 +229,30 @@ mod warning { /// this expands to an expression which evaluates to a `String`. /// /// If a default type arg is encountered, it will be added to the dependencies. -pub fn format_generics(deps: &mut Dependencies, generics: &Generics) -> TokenStream { +pub fn format_generics( + deps: &mut Dependencies, + crate_rename: &Path, + generics: &Generics, + concrete: &HashMap, +) -> TokenStream { let mut expanded_params = generics .params .iter() .filter_map(|param| match param { - GenericParam::Type(type_param) => Some({ + GenericParam::Type(type_param) => { + if concrete.contains_key(&type_param.ident) { + return None; + } let ty = type_param.ident.to_string(); if let Some(default) = &type_param.default { deps.push(default); - quote!(format!("{} = {}", #ty, <#default as ts_rs::TS>::name())) + Some(quote!( + format!("{} = {}", #ty, <#default as #crate_rename::TS>::name()) + )) } else { - quote!(#ty.to_owned()) + Some(quote!(#ty.to_owned())) } - }), + } _ => None, }) .peekable(); diff --git a/ts-rs/Cargo.toml b/ts-rs/Cargo.toml index 3ad9a333f..a4687ba84 100644 --- a/ts-rs/Cargo.toml +++ b/ts-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ts-rs" -version = "7.1.1" +version = "8.1.0" authors = ["Moritz Bischof "] edition = "2021" license = "MIT" @@ -15,6 +15,7 @@ categories = [ "web-programming", ] readme = "../README.md" +rust-version = "1.75.0" [features] chrono-impl = ["chrono"] @@ -30,19 +31,21 @@ indexmap-impl = ["indexmap"] ordered-float-impl = ["ordered-float"] heapless-impl = ["heapless"] semver-impl = ["semver"] +serde-json-impl = ["serde_json"] no-serde-warnings = ["ts-rs-macros/no-serde-warnings"] import-esm = [] [dev-dependencies] serde = { version = "1.0", features = ["derive"] } +serde_json = "1" chrono = { version = "0.4", features = ["serde"] } [dependencies] -heapless = { version = ">= 0.7, <= 0.8", optional = true } -ts-rs-macros = { version = "7.1.1", path = "../macros" } -dprint-plugin-typescript = { version = "0.89", optional = true } +heapless = { version = ">= 0.7, < 0.9", optional = true } +ts-rs-macros = { version = "=8.1.0", path = "../macros" } +dprint-plugin-typescript = { version = "0.90", optional = true } chrono = { version = "0.4", optional = true } -bigdecimal = { version = ">= 0.0.13, <= 0.4", features = [ +bigdecimal = { version = ">= 0.0.13, < 0.5", features = [ "serde", ], optional = true } uuid = { version = "1", optional = true } @@ -52,4 +55,5 @@ url = { version = "2", optional = true } semver = { version = "1", optional = true } thiserror = "1" indexmap = { version = "2", optional = true } -ordered-float = { version = ">= 3, <= 4", optional = true } +ordered-float = { version = ">= 3, < 5", optional = true } +serde_json = { version = "1", optional = true } diff --git a/ts-rs/src/chrono.rs b/ts-rs/src/chrono.rs index 25840919d..281ec4301 100644 --- a/ts-rs/src/chrono.rs +++ b/ts-rs/src/chrono.rs @@ -35,9 +35,15 @@ impl TS for DateTime { fn inline() -> String { "string".to_owned() } - fn inline_flattened() -> String { panic!("{} cannot be flattened", Self::name()) } - fn decl() -> String { panic!("{} cannot be declared", Self::name()) } - fn decl_concrete() -> String { panic!("{} cannot be declared", Self::name()) } + fn inline_flattened() -> String { + panic!("{} cannot be flattened", Self::name()) + } + fn decl() -> String { + panic!("{} cannot be declared", Self::name()) + } + fn decl_concrete() -> String { + panic!("{} cannot be declared", Self::name()) + } } impl TS for Date { @@ -51,7 +57,13 @@ impl TS for Date { fn inline() -> String { "string".to_owned() } - fn inline_flattened() -> String { panic!("{} cannot be flattened", Self::name()) } - fn decl() -> String { panic!("{} cannot be declared", Self::name()) } - fn decl_concrete() -> String { panic!("{} cannot be declared", Self::name()) } + fn inline_flattened() -> String { + panic!("{} cannot be flattened", Self::name()) + } + fn decl() -> String { + panic!("{} cannot be declared", Self::name()) + } + fn decl_concrete() -> String { + panic!("{} cannot be declared", Self::name()) + } } diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs index 12fe35505..07d35dfcc 100644 --- a/ts-rs/src/export.rs +++ b/ts-rs/src/export.rs @@ -1,12 +1,14 @@ use std::{ any::TypeId, + borrow::Cow, collections::BTreeMap, fmt::Write, + fs::File, path::{Component, Path, PathBuf}, sync::Mutex, }; -pub(crate) use recursive_export::export_type_with_dependencies; +pub(crate) use recursive_export::export_all_into; use thiserror::Error; use crate::TS; @@ -30,16 +32,27 @@ pub enum ExportError { } mod recursive_export { - use std::{any::TypeId, collections::HashSet}; + use std::{any::TypeId, collections::HashSet, path::Path}; - use super::export_type; + use super::export_into; use crate::{ typelist::{TypeList, TypeVisitor}, ExportError, TS, }; + /// Exports `T` to the file specified by the `#[ts(export_to = ..)]` attribute within the given + /// base directory. + /// Additionally, all dependencies of `T` will be exported as well. + pub(crate) fn export_all_into( + out_dir: impl AsRef, + ) -> Result<(), ExportError> { + let mut seen = HashSet::new(); + export_recursive::(&mut seen, out_dir) + } + struct Visit<'a> { seen: &'a mut HashSet, + out_dir: &'a Path, error: Option, } @@ -51,29 +64,27 @@ mod recursive_export { return; } - self.error = export_recursive::(self.seen).err(); + self.error = export_recursive::(self.seen, self.out_dir).err(); } } - /// Exports `T` to the file specified by the `#[ts(export_to = ..)]` attribute. - /// Additionally, all dependencies of `T` will be exported as well. - pub(crate) fn export_type_with_dependencies( - ) -> Result<(), ExportError> { - let mut seen = HashSet::new(); - export_recursive::(&mut seen) - } - // exports T, then recursively calls itself with all of its dependencies fn export_recursive( seen: &mut HashSet, + out_dir: impl AsRef, ) -> Result<(), ExportError> { if !seen.insert(TypeId::of::()) { return Ok(()); } + let out_dir = out_dir.as_ref(); - export_type::()?; + export_into::(out_dir)?; - let mut visitor = Visit { seen, error: None }; + let mut visitor = Visit { + seen, + out_dir, + error: None, + }; T::dependency_types().for_each(&mut visitor); if let Some(e) = visitor.error { @@ -85,15 +96,19 @@ mod recursive_export { } /// Export `T` to the file specified by the `#[ts(export_to = ..)]` attribute -pub(crate) fn export_type() -> Result<(), ExportError> { +pub(crate) fn export_into( + out_dir: impl AsRef, +) -> Result<(), ExportError> { let path = T::output_path() .ok_or_else(std::any::type_name::) .map_err(ExportError::CannotBeExported)?; - export_type_to::(path::absolute(path)?) + let path = out_dir.as_ref().join(path); + + export_to::(path::absolute(path)?) } /// Export `T` to the file specified by the `path` argument. -pub(crate) fn export_type_to>( +pub(crate) fn export_to>( path: P, ) -> Result<(), ExportError> { // Lock to make sure only one file will be written at a time. @@ -102,7 +117,7 @@ pub(crate) fn export_type_to>( static FILE_LOCK: Mutex<()> = Mutex::new(()); #[allow(unused_mut)] - let mut buffer = export_type_to_string::()?; + let mut buffer = export_to_string::()?; // format output #[cfg(feature = "format")] @@ -121,20 +136,35 @@ pub(crate) fn export_type_to>( std::fs::create_dir_all(parent)?; } let lock = FILE_LOCK.lock().unwrap(); - std::fs::write(path.as_ref(), buffer)?; + { + // Manually write to file & call `sync_data`. Otherwise, calling `fs::read(path)` + // immediately after `T::export()` might result in an empty file. + use std::io::Write; + let mut file = File::create(path)?; + file.write_all(buffer.as_bytes())?; + file.sync_data()?; + } + drop(lock); Ok(()) } -/// Returns the generated defintion for `T`. -pub(crate) fn export_type_to_string() -> Result { +/// Returns the generated definition for `T`. +pub(crate) fn export_to_string() -> Result { let mut buffer = String::with_capacity(1024); buffer.push_str(NOTE); - generate_imports::(&mut buffer)?; + generate_imports::(&mut buffer, default_out_dir())?; generate_decl::(&mut buffer); Ok(buffer) } +pub(crate) fn default_out_dir() -> Cow<'static, Path> { + match std::env::var("TS_RS_EXPORT_DIR") { + Err(..) => Cow::Borrowed(Path::new("./bindings")), + Ok(dir) => Cow::Owned(PathBuf::from(dir)), + } +} + /// Push the declaration of `T` fn generate_decl(out: &mut String) { // Type Docs @@ -148,11 +178,15 @@ fn generate_decl(out: &mut String) { out.push_str(&T::decl()); } -/// Push an import statement for all dependencies of `T` -fn generate_imports(out: &mut String) -> Result<(), ExportError> { +/// Push an import statement for all dependencies of `T`. +fn generate_imports( + out: &mut String, + out_dir: impl AsRef, +) -> Result<(), ExportError> { let path = T::output_path() .ok_or_else(std::any::type_name::) .map_err(ExportError::CannotBeExported)?; + let path = out_dir.as_ref().join(path); let deps = T::dependencies(); let deduplicated_deps = deps @@ -162,7 +196,8 @@ fn generate_imports(out: &mut String) -> Result<(), Ex .collect::>(); for (_, dep) in deduplicated_deps { - let rel_path = import_path(&path, Path::new(&dep.exported_to)); + let dep_path = out_dir.as_ref().join(dep.output_path); + let rel_path = import_path(&path, &dep_path); writeln!( out, "import type {{ {} }} from {:?};", diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 295287512..0954e6973 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -4,7 +4,7 @@ //! ts-rs //! //!

-//! generate typescript type declarations from rust types +//! Generate typescript type declarations from rust types //!

//! //!
//! -//! ## why? +//! ## Why? //! When building a web application in rust, data structures have to be shared between backend and frontend. //! Using this library, you can easily generate TypeScript bindings to your rust structs & enums so that you can keep your //! types in one place. //! //! ts-rs might also come in handy when working with webassembly. //! -//! ## how? +//! ## How? //! ts-rs exposes a single trait, `TS`. Using a derive macro, you can implement this interface for your types. //! Then, you can use this trait to obtain the TypeScript bindings. //! We recommend doing this in your tests. //! [See the example](https://github.com/Aleph-Alpha/ts-rs/blob/main/example/src/lib.rs) and [the docs](https://docs.rs/ts-rs/latest/ts_rs/). //! -//! ## get started +//! ## Get started //! ```toml //! [dependencies] -//! ts-rs = "7.1" +//! ts-rs = "8.1" //! ``` //! //! ```rust @@ -56,7 +56,7 @@ //! ``` //! When running `cargo test`, the TypeScript bindings will be exported to the file `bindings/User.ts`. //! -//! ## features +//! ## Features //! - generate type declarations from rust structs //! - generate union declarations from rust enums //! - inline types @@ -73,6 +73,7 @@ //! | format | Enables formatting of the generated TypeScript bindings.
Currently, this unfortunately adds quite a few dependencies. | //! | no-serde-warnings | By default, warnings are printed during build if unsupported serde attributes are encountered.
Enabling this feature silences these warnings. | //! | import-esm | When enabled,`import` statements in the generated file will have the `.js` extension in the end of the path to conform to the ES Modules spec.
Example: `import { MyStruct } from "./my_struct.js"` | +//! | serde-json-impl | Implement `TS` for types from *serde_json* | //! | chrono-impl | Implement `TS` for types from *chrono* | //! | bigdecimal-impl | Implement `TS` for types from *bigdecimal* | //! | url-impl | Implement `TS` for types from *url* | @@ -89,7 +90,7 @@ //! If there's a type you're dealing with which doesn't implement `TS`, use either //! `#[ts(as = "..")]` or `#[ts(type = "..")]`, or open a PR. //! -//! ## serde compatability +//! ## `serde` compatability //! With the `serde-compat` feature (enabled by default), serde attributes can be parsed for enums and structs. //! Supported serde attributes: //! - `rename` @@ -107,18 +108,13 @@ //! //! When ts-rs encounters an unsupported serde attribute, a warning is emitted, unless the feature `no-serde-warnings` is enabled. //! -//! ## contributing +//! ## Contributing //! Contributions are always welcome! //! Feel free to open an issue, discuss using GitHub discussions or open a PR. //! [See CONTRIBUTING.md](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md) //! -//! ## todo -//! - [x] serde compatibility layer -//! - [x] documentation -//! - [x] use typescript types across files -//! - [x] more enum representations -//! - [x] generics -//! - [x] don't require `'static` +//! ## MSRV +//! The Minimum Supported Rust Version for this crate is 1.75.0 use std::{ any::TypeId, @@ -140,6 +136,8 @@ use crate::typelist::TypeList; #[cfg(feature = "chrono-impl")] mod chrono; mod export; +#[cfg(feature = "serde-json-impl")] +mod serde_json; pub mod typelist; /// A type which can be represented in TypeScript. @@ -149,14 +147,24 @@ pub mod typelist; /// /// ### exporting /// Because Rusts procedural macros are evaluated before other compilation steps, TypeScript -/// bindings cannot be exported during compile time. +/// bindings __cannot__ be exported during compile time. +/// /// Bindings can be exported within a test, which ts-rs generates for you by adding `#[ts(export)]` -/// to a type you wish to export to a file. -/// If, for some reason, you need to do this during runtime, you can call [`TS::export`] yourself. +/// to a type you wish to export to a file. +/// When `cargo test` is run, all types annotated with `#[ts(export)]` and all of their +/// dependencies will be written to `TS_RS_EXPORT_DIR`, or `./bindings` by default. +/// +/// For each individual type, path and filename within the output directory can be changed using +/// `#[ts(export_to = "...")]`. By default, the filename will be derived from the name of the type. /// -/// **Note:** -/// Annotating a type with `#[ts(export)]` (or exporting it during runtime using -/// [`TS::export`]) will cause all of its dependencies to be exported as well. +/// If, for some reason, you need to do this during runtime or cannot use `#[ts(export)]`, bindings +/// can be exported manually: +/// +/// | Function | Includes Dependencies | To | +/// |-----------------------|-----------------------|--------------------| +/// | [`TS::export`] | ❌ | `TS_RS_EXPORT_DIR` | +/// | [`TS::export_all`] | ✔️ | `TS_RS_EXPORT_DIR` | +/// | [`TS::export_all_to`] | ✔️ | _custom_ | /// /// ### serde compatibility /// By default, the feature `serde-compat` is enabled. @@ -167,6 +175,10 @@ pub mod typelist; /// ### container attributes /// attributes applicable for both structs and enums /// +/// - **`#[ts(crate = "..")]`** +/// Generates code which references the module passed to it instead of defaulting to `::ts_rs` +/// This is useful for cases where you have to re-export the crate. +/// /// - **`#[ts(export)]`** /// Generates a test which will export the type, by default to `bindings/.ts` when running /// `cargo test`. The default base directory can be overridden with the `TS_RS_EXPORT_DIR` environment variable. @@ -187,13 +199,78 @@ pub mod typelist; /// Note that you need to add the `export` attribute as well, in order to generate a test which exports the type. ///

/// +/// - **`#[ts(as = "..")]`** +/// Overrides the type used in Typescript, using the provided Rust type instead. +/// This is useful when you have a custom serializer and deserializer and don't want to implement `TS` manually +///

+/// +/// - **`#[ts(type = "..")]`** +/// Overrides the type used in TypeScript. +/// This is useful when you have a custom serializer and deserializer and don't want to implement `TS` manually +///

+/// /// - **`#[ts(rename = "..")]`** /// Sets the typescript name of the generated type ///

/// /// - **`#[ts(rename_all = "..")]`** -/// Rename all fields/variants of the type. -/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" +/// Rename all fields/variants of the type. +/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" and "SCREAMING-KEBAB-CASE" +///

+/// +/// - **`#[ts(concrete(..)]`** +/// Disables one ore more generic type parameters by specifying a concrete type for them. +/// The resulting TypeScript definition will not be generic over these parameters and will use the +/// provided type instead. +/// This is especially useful for generic types containing associated types. Since TypeScript does +/// not have an equivalent construct to associated types, we cannot generate a generic definition +/// for them. Using `#[ts(concrete(..)]`, we can however generate a non-generic definition. +/// Example: +/// ``` +/// # use ts_rs::TS; +/// ##[derive(TS)] +/// ##[ts(concrete(I = std::vec::IntoIter))] +/// struct SearchResult(Vec); +/// // will always generate `type SearchResult = Array`. +/// ``` +///

+/// +/// - **`#[ts(bound)]`** +/// Override the bounds generated on the `TS` implementation for this type. This is useful in +/// combination with `#[ts(concrete)]`, when the type's generic parameters aren't directly used +/// in a field or variant. +/// +/// Example: +/// ``` +/// # use ts_rs::TS; +/// +/// trait Container { +/// type Value: TS; +/// } +/// +/// struct MyContainer; +/// +/// ##[derive(TS)] +/// struct MyValue; +/// +/// impl Container for MyContainer { +/// type Value = MyValue; +/// } +/// +/// ##[derive(TS)] +/// ##[ts(export, concrete(C = MyContainer))] +/// struct Inner { +/// value: C::Value, +/// } +/// +/// ##[derive(TS)] +/// // Without `#[ts(bound)]`, `#[derive(TS)]` would generate an unnecessary +/// // `C: TS` bound +/// ##[ts(export, concrete(C = MyContainer), bound = "C::Value: TS")] +/// struct Outer { +/// inner: Inner, +/// } +/// ``` ///

/// /// ### struct attributes @@ -254,13 +331,13 @@ pub mod typelist; /// /// - **`#[ts(rename_all = "..")]`** /// Rename all variants of this enum. -/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" +/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" and "SCREAMING-KEBAB-CASE" ///

/// /// - **`#[ts(rename_all_fieds = "..")]`** /// Renames the fields of all the struct variants of this enum. This is equivalent to using /// `#[ts(rename_all = "..")]` on all of the enum's variants. -/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" +/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" and "SCREAMING-KEBAB-CASE" ///

/// /// ### enum variant attributes @@ -280,7 +357,7 @@ pub mod typelist; /// /// - **`#[ts(rename_all = "..")]`** /// Renames all the fields of a struct variant. -/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" +/// Valid values are `lowercase`, `UPPERCASE`, `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE`, "kebab-case" and "SCREAMING-KEBAB-CASE" ///

pub trait TS { /// If this type does not have generic parameters, then `WithoutGenerics` should just be `Self`. @@ -380,51 +457,124 @@ pub trait TS { deps } - /// Manually export this type to a file. - /// The output file can be specified by annotating the type with `#[ts(export_to = ".."]`. - /// By default, the filename will be derived from the types name. + /// Manually export this type to the filesystem. + /// To export this type together with all of its dependencies, use [`TS::export_all`]. + /// + /// # Automatic Exporting + /// Types annotated with `#[ts(export)]`, together with all of their dependencies, will be + /// exported automatically whenever `cargo test` is run. + /// In that case, there is no need to manually call this function. /// - /// When a type is annotated with `#[ts(export)]`, it is exported automatically within a test. - /// This function is only usefull if you need to export the type outside of the context of a - /// test. + /// # Target Directory + /// The target directory to which the type will be exported may be changed by setting the + /// `TS_RS_EXPORT_DIR` environment variable. By default, `./bindings` will be used. + /// + /// To specify a target directory manually, use [`TS::export_all_to`], which also exports all + /// dependencies. + /// + /// To alter the filename or path of the type within the target directory, + /// use `#[ts(export_to = "...")]`. fn export() -> Result<(), ExportError> where Self: 'static, { - export::export_type_with_dependencies::() + let path = Self::default_output_path() + .ok_or_else(std::any::type_name::) + .map_err(ExportError::CannotBeExported)?; + + export::export_to::(path) } - /// Manually export this type to a file with a file with the specified path. This - /// function will ignore the `#[ts(export_to = "..)]` attribute. - fn export_to(path: impl AsRef) -> Result<(), ExportError> + /// Manually export this type to the filesystem, together with all of its dependencies. + /// To export only this type, without its dependencies, use [`TS::export`]. + /// + /// # Automatic Exporting + /// Types annotated with `#[ts(export)]`, together with all of their dependencies, will be + /// exported automatically whenever `cargo test` is run. + /// In that case, there is no need to manually call this function. + /// + /// # Target Directory + /// The target directory to which the types will be exported may be changed by setting the + /// `TS_RS_EXPORT_DIR` environment variable. By default, `./bindings` will be used. + /// + /// To specify a target directory manually, use [`TS::export_all_to`]. + /// + /// To alter the filenames or paths of the types within the target directory, + /// use `#[ts(export_to = "...")]`. + fn export_all() -> Result<(), ExportError> where Self: 'static, { - export::export_type_to::(path) + export::export_all_into::(&*export::default_out_dir()) + } + + /// Manually export this type into the given directory, together with all of its dependencies. + /// To export only this type, without its dependencies, use [`TS::export`]. + /// + /// Unlike [`TS::export_all`], this function disregards `TS_RS_EXPORT_DIR`, using the provided + /// directory instead. + /// + /// To alter the filenames or paths of the types within the target directory, + /// use `#[ts(export_to = "...")]`. + /// + /// # Automatic Exporting + /// Types annotated with `#[ts(export)]`, together with all of their dependencies, will be + /// exported automatically whenever `cargo test` is run. + /// In that case, there is no need to manually call this function. + fn export_all_to(out_dir: impl AsRef) -> Result<(), ExportError> + where + Self: 'static, + { + export::export_all_into::(out_dir) } /// Manually generate bindings for this type, returning a [`String`]. - /// This function does not format the output, even if the `format` feature is enabled. + /// This function does not format the output, even if the `format` feature is enabled. TODO + /// + /// # Automatic Exporting + /// Types annotated with `#[ts(export)]`, together with all of their dependencies, will be + /// exported automatically whenever `cargo test` is run. + /// In that case, there is no need to manually call this function. fn export_to_string() -> Result where Self: 'static, { - export::export_type_to_string::() + export::export_to_string::() } - /// Returns the output path to where `T` should be exported. + /// Returns the output path to where `T` should be exported. + /// The returned path does _not_ include the base directory from `TS_RS_EXPORT_DIR`. + /// + /// To get the output path containing `TS_RS_EXPORT_DIR`, use [`TS::default_output_path`]. /// /// When deriving `TS`, the output path can be altered using `#[ts(export_to = "...")]`. /// See the documentation of [`TS`] for more details. - /// + /// /// The output of this function depends on the environment variable `TS_RS_EXPORT_DIR`, which is /// used as base directory. If it is not set, `./bindings` is used as default directory. /// /// If `T` cannot be exported (e.g because it's a primitive type), this function will return /// `None`. - fn output_path() -> Option { + fn output_path() -> Option<&'static Path> { None } + + /// Returns the output path to where `T` should be exported. + /// + /// The output of this function depends on the environment variable `TS_RS_EXPORT_DIR`, which is + /// used as base directory. If it is not set, `./bindings` is used as default directory. + /// + /// To get the output path relative to `TS_RS_EXPORT_DIR` and without reading the environment + /// variable, use [`TS::output_path`]. + /// + /// When deriving `TS`, the output path can be altered using `#[ts(export_to = "...")]`. + /// See the documentation of [`TS`] for more details. + /// + /// If `T` cannot be exported (e.g because it's a primitive type), this function will return + /// `None`. + fn default_output_path() -> Option { + Some(export::default_out_dir().join(Self::output_path()?)) + } } /// A typescript type which is depended upon by other types. @@ -436,8 +586,9 @@ pub struct Dependency { /// Name of the type in TypeScript pub ts_name: String, /// Path to where the type would be exported. By default a filename is derived from the types - /// name, which can be customized with `#[ts(export_to = "..")]`. - pub exported_to: String, + /// name, which can be customized with `#[ts(export_to = "..")]`. + /// This path does _not_ include a base directory. + pub output_path: &'static Path, } impl Dependency { @@ -445,11 +596,11 @@ impl Dependency { /// If `T` is not exportable (meaning `T::EXPORT_TO` is `None`), this function will return /// `None` pub fn from_ty() -> Option { - let exported_to = T::output_path()?.to_str()?.to_owned(); + let output_path = T::output_path()?; Some(Dependency { type_id: TypeId::of::(), ts_name: T::ident(), - exported_to, + output_path, }) } } @@ -543,8 +694,9 @@ macro_rules! impl_shadow { { <$s>::generics() } - fn decl() -> String { panic!("{} cannot be declared", Self::name()) } - fn decl_concrete() -> String { panic!("{} cannot be declared", Self::name()) } + fn decl() -> String { <$s>::decl() } + fn decl_concrete() -> String { <$s>::decl_concrete() } + fn output_path() -> Option<&'static std::path::Path> { <$s>::output_path() } } }; } @@ -724,15 +876,15 @@ impl TS for HashMap { type WithoutGenerics = HashMap; fn ident() -> String { - "Record".to_owned() + panic!() } fn name() -> String { - format!("Record<{}, {}>", K::name(), V::name()) + format!("{{ [key: {}]: {} }}", K::name(), V::name()) } fn inline() -> String { - format!("Record<{}, {}>", K::inline(), V::inline()) + format!("{{ [key: {}]: {} }}", K::inline(), V::inline()) } fn dependency_types() -> impl TypeList @@ -868,8 +1020,11 @@ impl_primitives! { Ipv4Addr, Ipv6Addr, IpAddr, SocketAddrV4, SocketAddrV6, SocketAddr => "string", () => "null" } + #[rustfmt::skip] pub(crate) use impl_primitives; +#[rustfmt::skip] +pub(crate) use impl_shadow; #[doc(hidden)] #[derive(Copy, Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)] diff --git a/ts-rs/src/serde_json.rs b/ts-rs/src/serde_json.rs new file mode 100644 index 000000000..a5e63ed18 --- /dev/null +++ b/ts-rs/src/serde_json.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use super::{impl_primitives, impl_shadow, TS}; + +#[derive(TS)] +#[ts( + crate = "crate", + rename = "JsonValue", + untagged, + export_to = "serde_json/" +)] +pub enum TsJsonValue { + Number(i32), + String(String), + Array(Vec), + Object(HashMap), +} + +impl_shadow!(as TsJsonValue: impl TS for serde_json::Value); +impl_primitives!(serde_json::Number => "number"); +impl_shadow!(as HashMap: impl TS for serde_json::Map); diff --git a/ts-rs/src/typelist.rs b/ts-rs/src/typelist.rs index c54fdf2ba..123bf521f 100644 --- a/ts-rs/src/typelist.rs +++ b/ts-rs/src/typelist.rs @@ -1,11 +1,11 @@ -//! A simple zero-sized collection of types. +//! A simple zero-sized collection of types. use std::{any::TypeId, marker::PhantomData}; use crate::TS; /// A visitor used to iterate over a [`TypeList`]. -/// +/// /// Example: /// ``` /// # use ts_rs::TS; @@ -33,10 +33,10 @@ pub trait TypeVisitor: Sized { } /// A list containing types implementing `TS + 'static + ?Sized`. -/// -/// To construct a [`TypeList`], start with the empty list, which is the unit type `()`, and +/// +/// To construct a [`TypeList`], start with the empty list, which is the unit type `()`, and /// repeatedly call [`TypeList::push`] or [`TypeList::extend`] on it. -/// +/// /// Example: /// ``` /// # use ts_rs::typelist::TypeList; @@ -49,10 +49,10 @@ pub trait TypeVisitor: Sized { /// .extend(unsigned) /// } /// ``` -/// -/// The only way to get access to the types contained in a [`TypeList`] is to iterate over it by +/// +/// The only way to get access to the types contained in a [`TypeList`] is to iterate over it by /// creating a visitor implementing [`TypeVisitor`] and calling [`TypeList::for_each`]. -/// +/// /// Under the hood, [`TypeList`] is recursively defined as follows: /// - The unit type `()` is the empty [`TypeList`] /// - For every `T: TS`, `(PhantomData,)` is a [`TypeList`] diff --git a/ts-rs/tests/bound.rs b/ts-rs/tests/bound.rs new file mode 100644 index 000000000..485d2def0 --- /dev/null +++ b/ts-rs/tests/bound.rs @@ -0,0 +1,36 @@ +#![allow(dead_code)] + +use ts_rs::TS; + +trait Driver { + type Info; +} + +struct TsDriver; + +#[derive(TS)] +struct TsInfo; + +impl Driver for TsDriver { + type Info = TsInfo; +} + +#[derive(TS)] +#[ts(export, export_to = "bound/")] +#[ts(concrete(D = TsDriver))] +struct Inner { + info: D::Info, +} + +#[derive(TS)] +#[ts(export, export_to = "bound/")] +#[ts(concrete(D = TsDriver), bound = "D::Info: TS")] +struct Outer { + inner: Inner, +} + +#[test] +fn test_bound() { + assert_eq!(Outer::::decl(), "type Outer = { inner: Inner, };"); + assert_eq!(Inner::::decl(), "type Inner = { info: TsInfo, };"); +} diff --git a/ts-rs/tests/concrete_generic.rs b/ts-rs/tests/concrete_generic.rs new file mode 100644 index 000000000..5d45877c3 --- /dev/null +++ b/ts-rs/tests/concrete_generic.rs @@ -0,0 +1,91 @@ +#![allow(unused)] + +mod issue_261 { + use ts_rs::TS; + + trait Driver { + type Info; + } + + struct TsDriver; + impl Driver for TsDriver { + type Info = String; + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/issue_261/")] + struct OtherInfo { + x: i32, + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/issue_261/")] + struct OtherDriver; + impl Driver for OtherDriver { + type Info = OtherInfo; + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/issue_261/", concrete(T = TsDriver))] + struct Consumer1 { + info: T::Info, + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/issue_261/", concrete(T = OtherDriver))] + struct Consumer2 { + info: T::Info, + driver: T, + } + + #[test] + fn concrete_generic_param() { + assert_eq!( + Consumer1::::decl(), + "type Consumer1 = { info: string, };" + ); + // `decl` must use the concrete generic, no matter what we pass in + assert_eq!( + Consumer1::::decl(), + Consumer1::::decl() + ); + + assert_eq!( + Consumer2::::decl_concrete(), + "type Consumer2 = { info: OtherInfo, driver: OtherDriver, };" + ); + } +} + +mod simple { + use ts_rs::TS; + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/simple/")] + #[ts(concrete(T = i32))] + struct Simple { + t: T, + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/simple/")] + struct Tuple { + f: Option, + } + + #[derive(TS)] + #[ts(export, export_to = "concrete_generic/simple/")] + #[ts(concrete(T = i32))] + struct WithOption { + opt: Option, + } + + #[test] + fn simple() { + assert_eq!(Simple::::decl(), "type Simple = { t: number, };"); + assert_eq!( + WithOption::::decl(), + "type WithOption = { opt: number | null, };" + ); + } +} diff --git a/ts-rs/tests/docs.rs b/ts-rs/tests/docs.rs index 026df25f9..d989fa011 100644 --- a/ts-rs/tests/docs.rs +++ b/ts-rs/tests/docs.rs @@ -136,7 +136,7 @@ fn export_a() { ) }; - let actual_content = fs::read_to_string(A::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(A::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -182,7 +182,7 @@ fn export_b() { ) }; - let actual_content = fs::read_to_string(B::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(B::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -215,7 +215,7 @@ fn export_c() { ) }; - let actual_content = fs::read_to_string(C::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(C::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -247,7 +247,7 @@ fn export_d() { "export type D = null;" ) }; - let actual_content = fs::read_to_string(D::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(D::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -280,7 +280,7 @@ fn export_e() { ) }; - let actual_content = fs::read_to_string(E::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(E::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -328,7 +328,7 @@ fn export_f() { ) }; - let actual_content = fs::read_to_string(F::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(F::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -376,7 +376,7 @@ fn export_g() { ) }; - let actual_content = fs::read_to_string(G::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(G::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } diff --git a/ts-rs/tests/enum_flattening.rs b/ts-rs/tests/enum_flattening.rs index 6a0e1f91c..b86850b3e 100644 --- a/ts-rs/tests/enum_flattening.rs +++ b/ts-rs/tests/enum_flattening.rs @@ -24,12 +24,26 @@ enum BarExternally { Buz { c: String, d: Option }, } +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening/externally_tagged/")] +struct NestedExternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooExternally, + u: u32, +} + #[test] fn externally_tagged() { assert_eq!( FooExternally::inline(), r#"{ qux: number, biz: string | null, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"# - ) + ); + assert_eq!( + NestedExternally::inline(), + r#"{ u: number, qux: number, biz: string | null, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"# + ); } #[derive(TS)] @@ -65,12 +79,25 @@ enum BarAdjecently { }, } +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedAdjecently { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooAdjecently, + u: u32, +} + #[test] fn adjacently_tagged() { assert_eq!( FooAdjecently::inline(), r#"{ one: number, qux: string | null, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"# - ) + ); + assert_eq!( + NestedAdjecently::inline(), + r#"{ u: number, one: number, qux: string | null, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"# + ); } #[derive(TS)] @@ -95,23 +122,46 @@ enum BarInternally { Buz { c: String, d: Option }, } +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedInternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooInternally, + u: u32, +} + #[test] fn internally_tagged() { assert_eq!( FooInternally::inline(), r#"{ qux: string | null, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"# - ) + ); + assert_eq!( + NestedInternally::inline(), + r#"{ u: number, qux: string | null, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"# + ); } #[derive(TS)] #[ts(export, export_to = "enum_flattening/untagged/")] #[cfg_attr(feature = "serde-compat", derive(Serialize))] struct FooUntagged { + one: u32, #[cfg_attr(feature = "serde-compat", serde(flatten))] #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] baz: BarUntagged, } +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedUntagged { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooUntagged, + u: u32, +} + #[derive(TS)] #[ts(export, export_to = "enum_flattening/untagged/")] #[cfg_attr(feature = "serde-compat", derive(Serialize))] @@ -127,6 +177,10 @@ enum BarUntagged { fn untagged() { assert_eq!( FooUntagged::inline(), - r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"# - ) + r#"{ one: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"# + ); + assert_eq!( + NestedUntagged::inline(), + r#"{ u: number, one: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"# + ); } diff --git a/ts-rs/tests/enum_flattening_nested.rs b/ts-rs/tests/enum_flattening_nested.rs new file mode 100644 index 000000000..c15d690ef --- /dev/null +++ b/ts-rs/tests/enum_flattening_nested.rs @@ -0,0 +1,243 @@ +#![allow(dead_code)] + +#[cfg(feature = "serde-compat")] +use serde::Serialize; +use ts_rs::TS; + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct FooExternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + baz: BarExternally, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +enum BarExternally { + Baz { a: i32, a2: String }, + Biz { b: bool }, + Buz { c: String, d: Option }, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct NestedExternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooExternally, + u: u32, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct NestedExternallyLonely { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooExternally, +} + +#[test] +fn externally_tagged() { + // Notice here that baz is the only field inside `FooExternally`, so the parenthesis + // aren't needed + assert_eq!( + FooExternally::inline(), + r#"{ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } }"# + ); + + // But when flattening, the parenthesis are needed due to type intesections + assert_eq!( + NestedExternally::inline(), + r#"{ u: number, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"# + ); + + // And here, they are, again, unecessary + assert_eq!( + NestedExternallyLonely::inline(), + r#"{ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } }"# + ); +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/adjacently_tagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct FooAdjecently { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + baz: BarAdjecently, +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/adjacently_tagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[cfg_attr(feature = "serde-compat", serde(tag = "type", content = "stuff"))] +#[cfg_attr(not(feature = "serde-compat"), ts(tag = "type", content = "stuff"))] +enum BarAdjecently { + Baz { + a: i32, + a2: String, + }, + Biz { + b: bool, + }, + + #[cfg_attr(feature = "serde-compat", serde(untagged))] + #[cfg_attr(not(feature = "serde-compat"), ts(untagged))] + Buz { + c: String, + d: Option, + }, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedAdjecently { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooAdjecently, + u: u32, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct NestedAdjecentlyLonely { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooAdjecently, +} + +#[test] +fn adjacently_tagged() { + assert_eq!( + FooAdjecently::inline(), + r#"{ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, }"# + ); + + assert_eq!( + NestedAdjecently::inline(), + r#"{ u: number, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"# + ); + + assert_eq!( + NestedAdjecentlyLonely::inline(), + r#"{ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, }"# + ); +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/internally_tagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct FooInternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + baz: BarInternally, +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/internally_tagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[cfg_attr(feature = "serde-compat", serde(tag = "type"))] +#[cfg_attr(not(feature = "serde-compat"), ts(tag = "type"))] +enum BarInternally { + Baz { a: i32, a2: String }, + Biz { b: bool }, + Buz { c: String, d: Option }, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedInternally { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooInternally, + u: u32, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct NestedInternallyLonely { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooInternally, +} + +#[test] +fn internally_tagged() { + assert_eq!( + FooInternally::inline(), + r#"{ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, }"# + ); + + assert_eq!( + NestedInternally::inline(), + r#"{ u: number, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"# + ); + + assert_eq!( + NestedInternallyLonely::inline(), + r#"{ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, }"# + ); +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/untagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct FooUntagged { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + baz: BarUntagged, +} + +#[derive(TS)] +#[ts(export, export_to = "enum_flattening_nested/untagged/")] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[cfg_attr(feature = "serde-compat", serde(untagged))] +#[cfg_attr(not(feature = "serde-compat"), ts(untagged))] +enum BarUntagged { + Baz { a: i32, a2: String }, + Biz { b: bool }, + Buz { c: String }, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +struct NestedUntagged { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooUntagged, + u: u32, +} + +#[derive(TS)] +#[cfg_attr(feature = "serde-compat", derive(Serialize))] +#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")] +struct NestedUntaggedLonely { + #[cfg_attr(feature = "serde-compat", serde(flatten))] + #[cfg_attr(not(feature = "serde-compat"), ts(flatten))] + a: FooUntagged, +} + +#[test] +fn untagged() { + assert_eq!( + FooUntagged::inline(), + r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"# + ); + + assert_eq!( + NestedUntagged::inline(), + r#"{ u: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"# + ); + + assert_eq!( + NestedUntaggedLonely::inline(), + r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"# + ); +} diff --git a/ts-rs/tests/enum_struct_rename_all.rs b/ts-rs/tests/enum_struct_rename_all.rs index bbc798fd0..b43edcbc7 100644 --- a/ts-rs/tests/enum_struct_rename_all.rs +++ b/ts-rs/tests/enum_struct_rename_all.rs @@ -44,12 +44,16 @@ pub enum TaskStatus2 { stdout: String, stderr: String, }, + + A(i32), + B(i32, i32), + C, } #[test] pub fn enum_struct_rename_all_fields() { assert_eq!( TaskStatus2::inline(), - r#"{ "Running": { "started-time": string, } } | { "Terminated": { status: number, stdout: string, stderr: string, } }"# + r#"{ "Running": { "started-time": string, } } | { "Terminated": { status: number, stdout: string, stderr: string, } } | { "A": number } | { "B": [number, number] } | "C""# ) } diff --git a/ts-rs/tests/export_manually.rs b/ts-rs/tests/export_manually.rs index 8686a70a7..cdf111879 100644 --- a/ts-rs/tests/export_manually.rs +++ b/ts-rs/tests/export_manually.rs @@ -36,7 +36,7 @@ fn export_manually() { ) }; - let actual_content = fs::read_to_string(User::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(User::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } @@ -57,7 +57,7 @@ fn export_manually_dir() { ) }; - let actual_content = fs::read_to_string(UserDir::output_path().unwrap()).unwrap(); + let actual_content = fs::read_to_string(UserDir::default_output_path().unwrap()).unwrap(); assert_eq!(actual_content, expected_content); } diff --git a/ts-rs/tests/generic_without_import.rs b/ts-rs/tests/generic_without_import.rs new file mode 100644 index 000000000..d14704888 --- /dev/null +++ b/ts-rs/tests/generic_without_import.rs @@ -0,0 +1,6 @@ +#![allow(dead_code)] + +#[derive(ts_rs::TS)] +struct Test { + field: T, +} diff --git a/ts-rs/tests/generics.rs b/ts-rs/tests/generics.rs index e9e10ddf3..fdf4ec2d1 100644 --- a/ts-rs/tests/generics.rs +++ b/ts-rs/tests/generics.rs @@ -85,7 +85,7 @@ fn test() { assert_eq!( Container::decl(), - "type Container = { foo: Generic, bar: Array>, baz: Record>, };" + "type Container = { foo: Generic, bar: Array>, baz: { [key: string]: Generic }, };" ); } diff --git a/ts-rs/tests/hashmap.rs b/ts-rs/tests/hashmap.rs index 99512eae4..e6070b03a 100644 --- a/ts-rs/tests/hashmap.rs +++ b/ts-rs/tests/hashmap.rs @@ -15,7 +15,7 @@ struct Hashes { fn hashmap() { assert_eq!( Hashes::decl(), - "type Hashes = { map: Record, set: Array, };" + "type Hashes = { map: { [key: string]: string }, set: Array, };" ) } @@ -35,7 +35,7 @@ struct HashesHasher { fn hashmap_with_custom_hasher() { assert_eq!( HashesHasher::decl(), - "type HashesHasher = { map: Record, set: Array, };" + "type HashesHasher = { map: { [key: string]: string }, set: Array, };" ) } @@ -67,6 +67,6 @@ fn with_custom_types() { ); assert_eq!( HashMapWithCustomTypes::decl(), - "type HashMapWithCustomTypes = { map: Record, };" + "type HashMapWithCustomTypes = { map: { [key: CustomKey]: CustomValue }, };" ); } diff --git a/ts-rs/tests/imports.rs b/ts-rs/tests/imports.rs index a1e8ec3e3..85eca7c0c 100644 --- a/ts-rs/tests/imports.rs +++ b/ts-rs/tests/imports.rs @@ -3,19 +3,19 @@ use ts_rs::TS; #[derive(TS)] -#[ts(export, export_to = "imports/ts_rs_test_type_a.ts")] +#[ts(export_to = "imports/ts_rs_test_type_a.ts")] pub struct TestTypeA { value: T, } #[derive(TS)] -#[ts(export, export_to = "imports/ts_rs_test_type_b.ts")] +#[ts(export_to = "imports/ts_rs_test_type_b.ts")] pub struct TestTypeB { value: T, } #[derive(TS)] -#[ts(export, export_to = "imports/")] +#[ts(export_to = "imports/")] pub enum TestEnum { C { value: TestTypeB }, A1 { value: TestTypeA }, @@ -25,8 +25,8 @@ pub enum TestEnum { #[test] fn test_def() { // The only way to get access to how the imports look is to export the type and load the exported file - TestEnum::export().unwrap(); - let text = std::fs::read_to_string(TestEnum::output_path().unwrap()).unwrap(); + TestEnum::export_all().unwrap(); + let text = std::fs::read_to_string(TestEnum::default_output_path().unwrap()).unwrap(); let expected = match (cfg!(feature = "format"), cfg!(feature = "import-esm")) { (true, true) => concat!( diff --git a/ts-rs/tests/indexmap.rs b/ts-rs/tests/indexmap.rs index bf95b8abf..9af682a7b 100644 --- a/ts-rs/tests/indexmap.rs +++ b/ts-rs/tests/indexmap.rs @@ -15,6 +15,6 @@ struct Indexes { fn indexmap() { assert_eq!( Indexes::decl(), - "type Indexes = { map: Record, set: Array, };" + "type Indexes = { map: { [key: string]: string }, set: Array, };" ) } diff --git a/ts-rs/tests/issue-168.rs b/ts-rs/tests/issue-168.rs index 74908608b..d4f687e94 100644 --- a/ts-rs/tests/issue-168.rs +++ b/ts-rs/tests/issue-168.rs @@ -38,13 +38,13 @@ fn issue_168() { FooInlined::export_to_string().unwrap(), "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\ \n\ - export type FooInlined = { map: Record, }>, }>, };" + export type FooInlined = { map: { [key: number]: { map: { [key: number]: { map: { [key: number]: string }, } }, } }, };" ); assert_eq!( Foo::export_to_string().unwrap(), "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\ import type { Bar } from \"./Bar\";\n\ \n\ - export type Foo = { map: Record, };" + export type Foo = { map: { [key: number]: Bar }, };" ); } diff --git a/ts-rs/tests/issue-70.rs b/ts-rs/tests/issue-70.rs index cc57bdc1c..4db0ee847 100644 --- a/ts-rs/tests/issue-70.rs +++ b/ts-rs/tests/issue-70.rs @@ -24,11 +24,11 @@ struct Struct { fn issue_70() { assert_eq!( Enum::decl(), - "type Enum = { \"A\": Record } | { \"B\": Record };" + "type Enum = { \"A\": { [key: string]: string } } | { \"B\": { [key: string]: string } };" ); assert_eq!( Struct::decl(), - "type Struct = { a: Record, b: Record, };" + "type Struct = { a: { [key: string]: string }, b: { [key: string]: string }, };" ); } diff --git a/ts-rs/tests/leading_colon.rs b/ts-rs/tests/leading_colon.rs new file mode 100644 index 000000000..cf0eabd7d --- /dev/null +++ b/ts-rs/tests/leading_colon.rs @@ -0,0 +1,10 @@ +#![allow(unused)] + +use ::ts_rs::TS; + +mod ts_rs {} + +#[derive(TS)] +struct Foo { + x: u32, +} diff --git a/ts-rs/tests/lifetimes.rs b/ts-rs/tests/lifetimes.rs index b7b78fd89..93f0cee9f 100644 --- a/ts-rs/tests/lifetimes.rs +++ b/ts-rs/tests/lifetimes.rs @@ -31,6 +31,6 @@ fn contains_borrow() { fn contains_borrow_type_args() { assert_eq!( A::decl(), - "type A = { a: Array, b: Array>, c: Record, };" + "type A = { a: Array, b: Array>, c: { [key: string]: boolean }, };" ); } diff --git a/ts-rs/tests/path_bug.rs b/ts-rs/tests/path_bug.rs index fc8cbd1fb..12ade1bf1 100644 --- a/ts-rs/tests/path_bug.rs +++ b/ts-rs/tests/path_bug.rs @@ -2,13 +2,13 @@ use ts_rs::TS; #[derive(TS)] -#[ts(export, export_to = "../ts-rs/path_bug/")] +#[ts(export, export_to = "path_bug/aaa/")] struct Foo { bar: Bar, } #[derive(TS)] -#[ts(export_to = "path_bug/aaa/")] +#[ts(export_to = "../bindings/path_bug/")] struct Bar { i: i32, } @@ -17,6 +17,6 @@ struct Bar { fn path_bug() { export_bindings_foo(); - assert!(Foo::output_path().unwrap().is_file()); - assert!(Bar::output_path().unwrap().is_file()); + assert!(Foo::default_output_path().unwrap().is_file()); + assert!(Bar::default_output_path().unwrap().is_file()); } diff --git a/ts-rs/tests/recursion_limit.rs b/ts-rs/tests/recursion_limit.rs new file mode 100644 index 000000000..1f0f98ae1 --- /dev/null +++ b/ts-rs/tests/recursion_limit.rs @@ -0,0 +1,82 @@ +use std::any::TypeId; + +use ts_rs::{ + typelist::{TypeList, TypeVisitor}, + TS, +}; + +#[rustfmt::skip] +#[derive(Debug, ts_rs::TS)] +#[ts(export, export_to = "very_big_types/")] +pub enum Iso4217CurrencyCode { + AED, AFN, ALL, AMD, ANG, AOA, ARS, AUD, AWG, AZN, BAM, BBD, BDT, BGN, BHD, BIF, BMD, BND, BOB, + BRL, BSD, BTN, BWP, BYN, BZD, CAD, CDF, CHF, CLP, CNY, COP, CRC, CUC, CUP, CVE, CZK, DJF, DKK, + DOP, DZD, EGP, ERN, ETB, EUR, FJD, FKP, GBP, GEL, GGP, GHS, GIP, GMD, GNF, GTQ, GYD, HKD, HNL, + HRK, HTG, HUF, IDR, ILS, IMP, INR, IQD, IRR, ISK, JEP, JMD, JOD, JPY, KES, KGS, KHR, KMF, KPW, + KRW, KWD, KYD, KZT, LAK, LBP, LKR, LRD, LSL, LYD, MAD, MDL, MGA, MKD, MMK, MNT, MOP, MRU, MUR, + MVR, MWK, MXN, MYR, MZN, NAD, NGN, NIO, NOK, NPR, NZD, OMR, PAB, PEN, PGK, PHP, PKR, PLN, PYG, + QAR, RON, RSD, RUB, RWF, SAR, SBD, SCR, SDG, SEK, SGD, SHP, SLL, SOS, SPL, SRD, STN, SVC, SYP, + SZL, THB, TJS, TMT, TND, TOP, TRY, TTD, TVD, TWD, TZS, UAH, UGX, USD, UYU, UZS, VEF, VND, VUV, + WST, XAF, XCD, XDR, XOF, XPF, YER, ZAR, ZMW, ZWD, +} + +#[rustfmt::skip] +#[derive(Debug, ts_rs::TS)] +#[ts(export, export_to = "very_big_types/")] +pub enum VeryBigEnum { + V001(String), V002(String), V003(String), V004(String), V005(String), V006(String), V007(String), + V008(String), V009(String), V010(String), V011(String), V012(String), V013(String), V014(String), + V015(String), V016(String), V017(String), V018(String), V019(String), V020(String), V021(String), + V022(String), V023(String), V024(String), V025(String), V026(String), V027(String), V028(String), + V029(String), V030(String), V031(String), V032(String), V033(String), V034(String), V035(String), + V036(String), V037(String), V038(String), V039(String), V040(String), V041(String), V042(String), + V043(String), V044(String), V045(String), V046(String), V047(String), V048(String), V049(String), + V050(String), V051(String), V052(String), V053(String), V054(String), V055(String), V056(String), + V057(String), V058(String), V059(String), V060(String), V061(String), V062(String), V063(String), + V064(String), V065(String), V066(String), V067(String), V068(String), V069(String), V070(String), + V071(String), V072(String), V073(String), V074(String), V075(String), V076(String), V077(String), + V078(String), V079(String), V080(String), V081(String), V082(String), V083(String), V084(String), + V085(String), V086(String), V087(String), V088(String), V089(String), V090(String), V091(String), + V092(String), V093(String), V094(String), V095(String), V096(String), V097(String), V098(String), + V099(String), V100(String), V101(String), V102(String), V103(String), V104(String), V105(String), + V106(String), V107(String), V108(String), V109(String), V110(String), V111(String), V112(String), + V113(String), V114(String), V115(String), V116(String), V117(String), V118(String), V119(String), + V120(String), V121(String), V122(String), V123(String), V124(String), V125(String), V126(String), + V127(String), V128(String), V129(String), V130(String), V131(String), V132(String), V133(String), + V134(String), V135(String), V136(String), V137(String), V138(String), V139(String), V140(String), + V141(String), V142(String), V143(String), V144(String), V145(String), V146(String), V147(String), + V148(String), V149(String), V150(String), V151(String), V152(String), V153(String), V154(String), + V155(String), V156(String), V157(String), V158(String), V159(String), V160(String), V161(String), + V162(String), V163(String), V164(String), V165(String), V166(String), V167(String), V168(String), + V169(String), V170(String), V171(String), V172(String), V173(String), V174(String), V175(String), + V176(String), V177(String), V178(String), V179(String), V180(String), V181(String), V182(String), + V183(String), V184(String), V185(String), V186(String), V187(String), V188(String), V189(String), + V190(String), V191(String), V192(String), V193(String), V194(String), V195(String), V196(String), + V197(String), V198(String), V199(String), V200(String), V201(String), V202(String), V203(String), + V204(String), V205(String), V206(String), V207(String), V208(String), V209(String), V210(String), + V211(String), V212(String), V213(String), V214(String), V215(String), V216(String), V217(String), + V218(String), V219(String), V220(String), V221(String), V222(String), V223(String), V224(String), + V225(String), V226(String), V227(String), V228(String), V229(String), V230(String), V231(String), + V232(String), V233(String), V234(String), V235(String), V236(String), V237(String), V238(String), + V239(String), V240(String), V241(String), V242(String), V243(String), V244(String), V245(String), + V246(String), V247(String), V248(String), V249(String), V250(String), V251(String), V252(String), + V253(String), V254(String), V255(String), V256(String), +} + +#[test] +fn very_big_enum() { + struct Visitor(bool); + + impl TypeVisitor for Visitor { + fn visit(&mut self) { + assert!(!self.0, "there must only be one dependency"); + assert_eq!(TypeId::of::(), TypeId::of::()); + self.0 = true; + } + } + + let mut visitor = Visitor(false); + VeryBigEnum::dependency_types().for_each(&mut visitor); + + assert!(visitor.0, "there must be at least one dependency"); +} diff --git a/ts-rs/tests/self_referential.rs b/ts-rs/tests/self_referential.rs index c2a0e8a7d..067f00766 100644 --- a/ts-rs/tests/self_referential.rs +++ b/ts-rs/tests/self_referential.rs @@ -1,5 +1,4 @@ #![allow(dead_code)] - use std::{collections::HashMap, sync::Arc}; #[cfg(feature = "serde-compat")] @@ -84,8 +83,8 @@ fn enum_externally_tagged() { { \"C\": E } | \ { \"D\": E } | \ { \"E\": [E, E, E, E] } | \ - { \"F\": { a: E, b: E, c: Record, d: E | null, e?: E | null, f?: E, } } | \ - { \"G\": [Array, Array, Record] };" + { \"F\": { a: E, b: E, c: { [key: string]: E }, d: E | null, e?: E | null, f?: E, } } | \ + { \"G\": [Array, Array, { [key: string]: E }] };" ); } @@ -123,7 +122,7 @@ fn enum_internally_tagged() { { \"tag\": \"C\" } & I | \ { \"tag\": \"D\" } & I | \ { \"tag\": \"E\" } & Array | \ - { \"tag\": \"F\", a: I, b: I, c: Record, d: I | null, e?: I | null, f?: I, };" + { \"tag\": \"F\", a: I, b: I, c: { [key: I]: I }, d: I | null, e?: I | null, f?: I, };" ); } @@ -171,7 +170,7 @@ fn enum_adjacently_tagged() { \"content\": { \ a: A, \ b: A, \ - c: Record, \ + c: { [key: string]: A }, \ d: A | null, \ e?: A | null, \ f?: A, \ @@ -180,9 +179,9 @@ fn enum_adjacently_tagged() { { \ \"tag\": \"G\", \ \"content\": [\ - Array, \ - [A, A, A, A], \ - Record\ + Array, \ + [A, A, A, A], \ + { [key: string]: A }\ ] \ };" ); diff --git a/ts-rs/tests/serde-skip-with-default.rs b/ts-rs/tests/serde-skip-with-default.rs index fc11b9b63..b67e2e581 100644 --- a/ts-rs/tests/serde-skip-with-default.rs +++ b/ts-rs/tests/serde-skip-with-default.rs @@ -12,7 +12,7 @@ fn default_http_version() -> String { #[derive(Debug, Clone, Deserialize, Serialize, TS)] #[ts(export, export_to = "serde_skip_with_default/")] pub struct Foobar { - #[ts(skip)] + // #[ts(skip)] #[serde(skip, default = "default_http_version")] pub http_version: String, pub something_else: i32, diff --git a/ts-rs/tests/serde_json.rs b/ts-rs/tests/serde_json.rs new file mode 100644 index 000000000..29e32c786 --- /dev/null +++ b/ts-rs/tests/serde_json.rs @@ -0,0 +1,65 @@ +#![cfg(feature = "serde_json")] +#![allow(unused)] + +use ts_rs::TS; + +#[derive(TS)] +#[ts(export, export_to = "serde_json_impl/")] +struct UsingSerdeJson { + num: serde_json::Number, + map1: serde_json::Map, + map2: serde_json::Map, + map3: serde_json::Map>, + map4: serde_json::Map, + map5: serde_json::Map, + any: serde_json::Value, +} + +#[test] +fn using_serde_json() { + assert_eq!(serde_json::Number::inline(), "number"); + assert_eq!( + serde_json::Map::::inline(), + "{ [key: string]: number }" + ); + assert_eq!( + serde_json::Value::decl(), + "type JsonValue = number | string | Array | { [key: string]: JsonValue };", + ); + + assert_eq!( + UsingSerdeJson::decl(), + "type UsingSerdeJson = { \ + num: number, \ + map1: { [key: string]: number }, \ + map2: { [key: string]: UsingSerdeJson }, \ + map3: { [key: string]: { [key: string]: number } }, \ + map4: { [key: string]: number }, \ + map5: { [key: string]: JsonValue }, \ + any: JsonValue, \ + };" + ) +} + +#[derive(TS)] +#[ts(export, export_to = "serde_json_impl/")] +struct InlinedValue { + #[ts(inline)] + any: serde_json::Value, +} + +#[test] +fn inlined_value() { + assert_eq!( + InlinedValue::decl(), + "type InlinedValue = { \ + any: number | string | Array | { [key: string]: JsonValue }, \ + };" + ); +} + +#[derive(TS)] +#[ts(export, export_to = "serde_json_impl/")] +struct Simple { + json: serde_json::Value, +} diff --git a/ts-rs/tests/serde_with.rs b/ts-rs/tests/serde_with.rs new file mode 100644 index 000000000..edc13494e --- /dev/null +++ b/ts-rs/tests/serde_with.rs @@ -0,0 +1,66 @@ +#![allow(unused, dead_code, clippy::disallowed_names)] + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, TS)] +struct Foo { + a: i32, +} + +#[derive(Serialize, Deserialize, TS)] +struct Bar { + a: i32, +} + +mod deser { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::Foo; + + pub fn serialize(foo: &Foo, serializer: S) -> Result { + foo.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Foo::deserialize(deserializer) + } +} + +// This test should pass when serde-compat is disabled, +// otherwise, it should fail to compile +#[test] +#[cfg(not(feature = "serde-compat"))] +fn no_serde_compat() { + #[derive(Serialize, Deserialize, TS)] + struct Baz { + #[serde(with = "deser")] + a: Foo, + } + + assert_eq!(Baz::inline(), "{ a: Foo, }") +} + +#[test] +fn serde_compat_as() { + #[derive(Serialize, Deserialize, TS)] + struct Baz { + #[serde(with = "deser")] + #[ts(as = "Bar")] + a: Foo, + } + + assert_eq!(Baz::inline(), "{ a: Bar, }") +} + +#[test] +fn serde_compat_type() { + #[derive(Serialize, Deserialize, TS)] + struct Baz { + #[serde(with = "deser")] + #[ts(type = "{ a: number }")] + a: Foo, + } + + assert_eq!(Baz::inline(), "{ a: { a: number }, }") +} diff --git a/ts-rs/tests/struct_rename.rs b/ts-rs/tests/struct_rename.rs index af8a93f34..9536fcaa4 100644 --- a/ts-rs/tests/struct_rename.rs +++ b/ts-rs/tests/struct_rename.rs @@ -4,11 +4,7 @@ use ts_rs::TS; #[derive(TS)] -#[ts( - export, - export_to = "struct_rename/", - rename_all = "UPPERCASE" -)] +#[ts(export, export_to = "struct_rename/", rename_all = "UPPERCASE")] struct RenameAllUpper { a: i32, b: i32, @@ -20,11 +16,7 @@ fn rename_all() { } #[derive(TS)] -#[ts( - export, - export_to = "struct_rename/", - rename_all = "camelCase" -)] +#[ts(export, export_to = "struct_rename/", rename_all = "camelCase")] struct RenameAllCamel { crc32c_hash: i32, b: i32, @@ -40,11 +32,7 @@ fn rename_all_camel_case() { } #[derive(TS)] -#[ts( - export, - export_to = "struct_rename/", - rename_all = "PascalCase" -)] +#[ts(export, export_to = "struct_rename/", rename_all = "PascalCase")] struct RenameAllPascal { crc32c_hash: i32, b: i32, @@ -58,12 +46,31 @@ fn rename_all_pascal_case() { ); } +#[derive(TS, Default, serde::Serialize)] +#[ts(export, export_to = "struct_rename/")] +#[cfg_attr(feature = "serde-compat", serde(rename_all = "SCREAMING-KEBAB-CASE"))] +#[cfg_attr(not(feature = "serde-compat"), ts(rename_all = "SCREAMING-KEBAB-CASE"))] +struct RenameAllScreamingKebab { + crc32c_hash: i32, + some_field: i32, + some_other_field: i32, +} + +#[test] +fn rename_all_screaming_kebab_case() { + let rename_all = RenameAllScreamingKebab::default(); + assert_eq!( + serde_json::to_string(&rename_all).unwrap(), + r#"{"CRC32C-HASH":0,"SOME-FIELD":0,"SOME-OTHER-FIELD":0}"# + ); + assert_eq!( + RenameAllScreamingKebab::inline(), + r#"{ "CRC32C-HASH": number, "SOME-FIELD": number, "SOME-OTHER-FIELD": number, }"# + ); +} + #[derive(serde::Serialize, TS)] -#[ts( - export, - export_to = "struct_rename/", - rename_all = "camelCase" -)] +#[ts(export, export_to = "struct_rename/", rename_all = "camelCase")] struct RenameSerdeSpecialChar { #[serde(rename = "a/b")] b: i32, diff --git a/ts-rs/tests/top_level_type_as.rs b/ts-rs/tests/top_level_type_as.rs new file mode 100644 index 000000000..940c855f1 --- /dev/null +++ b/ts-rs/tests/top_level_type_as.rs @@ -0,0 +1,22 @@ +use ts_rs::TS; + +#[derive(TS)] +#[ts(as = "T")] +pub enum UntaggedEnum { + Left(T), + Right(T), +} + +#[test] +pub fn top_level_type_as_enum() { + assert_eq!(UntaggedEnum::::inline(), r#"string"#) +} + +#[derive(TS)] +#[ts(as = "T")] +pub struct Wrapper(T); + +#[test] +pub fn top_level_type_as_struct() { + assert_eq!(Wrapper::::inline(), r#"string"#) +} diff --git a/ts-rs/tests/top_level_type_override.rs b/ts-rs/tests/top_level_type_override.rs new file mode 100644 index 000000000..e78f0b762 --- /dev/null +++ b/ts-rs/tests/top_level_type_override.rs @@ -0,0 +1,30 @@ +use ts_rs::TS; + +#[derive(TS)] +#[ts(export, export_to = "top_level_type_override/")] +#[ts(type = "string")] +#[non_exhaustive] +pub enum IncompleteEnum { + Foo, + Bar, + Baz, + // more +} + +#[test] +pub fn top_level_type_override_enum() { + assert_eq!(IncompleteEnum::inline(), r#"string"#) +} + +#[derive(TS)] +#[ts(export, export_to = "top_level_type_override/")] +#[ts(type = "string")] +pub struct DataUrl { + pub mime: String, + pub contents: Vec, +} + +#[test] +pub fn top_level_type_override_struct() { + assert_eq!(DataUrl::inline(), r#"string"#) +} diff --git a/ts-rs/tests/type_as.rs b/ts-rs/tests/type_as.rs index 65e653c7b..4b55bb14c 100644 --- a/ts-rs/tests/type_as.rs +++ b/ts-rs/tests/type_as.rs @@ -26,13 +26,20 @@ struct Override { // here, 'as' just behaves like 'type' (though it adds a dependency!) #[ts(as = "ExternalTypeDef")] y: Unsupported, + #[ts(as = "(i32, ExternalTypeDef, i32)")] + z: Unsupported, } #[test] fn struct_properties() { assert_eq!( Override::inline(), - "{ a: number, x: { a: number, b: number, c: number, }, y: ExternalTypeDef, }" + "{ \ + a: number, \ + x: { a: number, b: number, c: number, }, \ + y: ExternalTypeDef, \ + z: [number, ExternalTypeDef, number], \ + }" ); assert!(Override::dependencies() .iter()