mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
feat: add automatic types generation (#703)
This commit is contained in:
@@ -43,7 +43,7 @@ jobs:
|
||||
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
|
||||
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check_rust:
|
||||
check_example:
|
||||
needs:
|
||||
- example_list
|
||||
|
||||
@@ -87,26 +87,11 @@ jobs:
|
||||
working-directory: ./examples/${{ matrix.example_name }}
|
||||
run: ../../target/debug/tuono build
|
||||
|
||||
check_typescript:
|
||||
name: Check typescript
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Build typescript
|
||||
run: pnpm run build
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm run examples:format
|
||||
run: pnpm run format
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run examples:typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
ci_ok:
|
||||
name: Examples CI OK
|
||||
@@ -115,8 +100,7 @@ jobs:
|
||||
if: always()
|
||||
needs:
|
||||
- example_list
|
||||
- check_rust
|
||||
- check_typescript
|
||||
- check_example
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
|
||||
@@ -18,6 +18,7 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
||||
syn = { version = "2.0.100", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
||||
miette = "7.2.0"
|
||||
@@ -38,6 +39,7 @@ http = "1.1.0"
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.19.3"}
|
||||
spinners = "4.1.1"
|
||||
console = "0.15.10"
|
||||
convert_case = "0.8.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6.2"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::mode::Mode;
|
||||
use crate::route::Route;
|
||||
use glob::GlobError;
|
||||
use glob::glob;
|
||||
use glob::{GlobError, glob};
|
||||
use http::Method;
|
||||
use std::collections::hash_set::HashSet;
|
||||
use std::collections::{HashMap, hash_map::Entry};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
use tuono_internal::tuono_println;
|
||||
use watchexec_events::Tag;
|
||||
use watchexec_events::filekind::FileEventKind;
|
||||
@@ -32,7 +34,7 @@ fn ssr_reload_needed(path: &Path) -> bool {
|
||||
clippy::await_holding_lock,
|
||||
reason = "At this point there is no other thread waiting for the lock"
|
||||
)]
|
||||
async fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
||||
async unsafe fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.start_dev_processes().await
|
||||
}
|
||||
@@ -59,7 +61,12 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
||||
|
||||
let env_files = detect_existing_env_files();
|
||||
|
||||
start_all_processes(process_manager.clone()).await;
|
||||
unsafe {
|
||||
// It is safe to call this function because here
|
||||
// only one thread is running and the lock is not
|
||||
// needed by any other thread.
|
||||
start_all_processes(process_manager.clone()).await;
|
||||
}
|
||||
|
||||
// Remove the spinner
|
||||
sp.stop();
|
||||
@@ -94,38 +101,59 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
||||
let mut should_reload_rust_server = false;
|
||||
let mut should_refresh_axum_source = false;
|
||||
|
||||
// Using HashSet to avoid duplicates
|
||||
let mut paths_to_refresh_types: HashSet<PathBuf> = HashSet::new();
|
||||
let mut removed_files_from_types: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
for event in action.events.iter() {
|
||||
for event_type in event.tags.iter() {
|
||||
if let Tag::FileEventKind(kind) = event_type {
|
||||
match kind {
|
||||
FileEventKind::Remove(_) => {
|
||||
if event.paths().any(|(path, _)| {
|
||||
path.extension().is_some_and(|ext| ext == "rs") ||
|
||||
// APIs might define new HTTP methods that requires
|
||||
// a refresh of the axum source
|
||||
path.to_str().unwrap_or("").contains("api")
|
||||
}) {
|
||||
FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
removed_files_from_types.insert(path.to_path_buf());
|
||||
should_refresh_axum_source = true;
|
||||
}
|
||||
}
|
||||
FileEventKind::Modify(_) => {
|
||||
if event
|
||||
.paths()
|
||||
.any(|(path, _)| path.extension().is_some_and(|ext| ext == "rs"))
|
||||
{
|
||||
}),
|
||||
FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
should_reload_rust_server = true;
|
||||
paths_to_refresh_types.insert(path.to_path_buf());
|
||||
}
|
||||
|
||||
if event.paths().any(|(path, _)| ssr_reload_needed(path)) {
|
||||
if ssr_reload_needed(path) {
|
||||
should_reload_ssr_bundle = true;
|
||||
}
|
||||
}
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !paths_to_refresh_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in paths_to_refresh_types {
|
||||
// There is no need to check here if the `Type` trait is
|
||||
// derived since it will be checked later by the TypeJar struct.
|
||||
builder.refresh_typescript_file(path)
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if !removed_files_from_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in removed_files_from_types {
|
||||
builder.remove_typescript_file(path);
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_rust_server || should_refresh_axum_source {
|
||||
tuono_println!("Reloading...");
|
||||
if should_refresh_axum_source {
|
||||
|
||||
@@ -10,3 +10,5 @@ mod mode;
|
||||
mod process_manager;
|
||||
mod route;
|
||||
mod source_builder;
|
||||
mod symbols;
|
||||
mod typescript;
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ProcessManager {
|
||||
let mut processes = HashMap::new();
|
||||
processes.insert(
|
||||
ProcessId::WatchReactSrc,
|
||||
start_supervisor_job(DEV_WATCH_BIN_SRC, vec![]),
|
||||
start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
@@ -63,7 +63,7 @@ impl ProcessManager {
|
||||
|
||||
processes.insert(
|
||||
ProcessId::BuildReactSSRSrc,
|
||||
start_supervisor_job(DEV_SSR_BIN_SRC, vec![]),
|
||||
start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
Self { processes }
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
use crate::typescript::TypesJar;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
|
||||
@@ -59,6 +60,7 @@ pub struct SourceBuilder {
|
||||
pub app: App,
|
||||
mode: Mode,
|
||||
base_path: PathBuf,
|
||||
types_jar: TypesJar,
|
||||
}
|
||||
|
||||
impl SourceBuilder {
|
||||
@@ -79,20 +81,23 @@ impl SourceBuilder {
|
||||
Ok(Self {
|
||||
app,
|
||||
mode,
|
||||
types_jar: TypesJar::from(&base_path),
|
||||
base_path,
|
||||
})
|
||||
}
|
||||
|
||||
// Build the source code needed for both build and dev
|
||||
pub fn base_build(&mut self) -> io::Result<()> {
|
||||
let Self { mode, .. } = &self;
|
||||
let mode = self.mode.clone();
|
||||
|
||||
self.refresh_axum_source()?;
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
|
||||
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
|
||||
|
||||
if mode == &Mode::Dev {
|
||||
self.types_jar.generate_typescript_file(&self.base_path)?;
|
||||
|
||||
if mode == Mode::Dev {
|
||||
self.app.build_tuono_config()?;
|
||||
let fallback_html = self.build_html_fallback();
|
||||
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
|
||||
@@ -165,6 +170,18 @@ impl SourceBuilder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refresh_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.refresh_file(path);
|
||||
}
|
||||
|
||||
pub fn remove_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.remove_file(path);
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self) -> io::Result<()> {
|
||||
self.types_jar.generate_typescript_file(&self.base_path)
|
||||
}
|
||||
|
||||
fn create_routes_declaration(&self) -> String {
|
||||
let routes = &self.app.route_map;
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
@@ -244,6 +261,7 @@ mod tests {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
@@ -251,6 +269,7 @@ mod tests {
|
||||
app: App::new(),
|
||||
mode: Mode::Prod,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
@@ -264,6 +283,7 @@ mod tests {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
@@ -276,6 +296,7 @@ mod tests {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let mut route = Route::new(String::from("index.tsx"));
|
||||
@@ -300,6 +321,7 @@ mod tests {
|
||||
app,
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let fallback_html = source_builder.build_html_fallback();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use syn::Ident;
|
||||
|
||||
pub struct Symbol(&'static str);
|
||||
|
||||
// The trait name that allow automatic struct/enum/type conversion
|
||||
// to typescript
|
||||
pub const TYPE_TRAIT: Symbol = Symbol("Type");
|
||||
|
||||
impl PartialEq<Symbol> for Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Symbol> for &Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
*self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Symbol {
|
||||
type Target = &'static str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::utils::has_derive_type;
|
||||
use crate::typescript::parser::{parse_enum, parse_struct};
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use tracing::trace;
|
||||
|
||||
/// Represents all the valid typescript types found in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTypes {
|
||||
/// Rust file when the type was found
|
||||
pub file_path: PathBuf,
|
||||
/// All the types found in the file
|
||||
/// ready to be printed in the typescript file
|
||||
pub types_as_string: String,
|
||||
/// The types found in the file.
|
||||
/// Used to check that the types are not duplicated across files.
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<(PathBuf, String)> for FileTypes {
|
||||
type Error = Box<dyn Error>;
|
||||
|
||||
fn try_from((file_path, file_str): (PathBuf, String)) -> Result<Self, Self::Error> {
|
||||
trace!("Parsing file: {:?}", &file_path);
|
||||
let file = syn::parse_file(&file_str)?;
|
||||
|
||||
let mut types_as_string = String::new();
|
||||
let mut types = Vec::new();
|
||||
|
||||
for item in file.items {
|
||||
match item {
|
||||
syn::Item::Struct(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found struct in file: {:?}", &file_path);
|
||||
let (struct_name, typescript_definition) = parse_struct(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(struct_name);
|
||||
}
|
||||
syn::Item::Enum(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found enum in file: {:?}", &file_path);
|
||||
|
||||
let (enum_name, typescript_definition) = parse_enum(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(enum_name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if types_as_string.is_empty() {
|
||||
return Err("No types found in the file".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
file_path,
|
||||
types_as_string,
|
||||
types,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_creates_type_from_pathbuf_and_string() {
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field1: String,
|
||||
field2: i32,
|
||||
}
|
||||
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
"#
|
||||
.to_string();
|
||||
|
||||
let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap();
|
||||
|
||||
assert_eq!(ttype.file_path, file_path);
|
||||
assert_eq!(
|
||||
ttype.types_as_string,
|
||||
"export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n"
|
||||
);
|
||||
assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod file_types;
|
||||
pub mod parser;
|
||||
|
||||
mod types_jar;
|
||||
pub mod utils;
|
||||
|
||||
pub use file_types::*;
|
||||
pub use types_jar::*;
|
||||
@@ -0,0 +1,6 @@
|
||||
mod parse_enum;
|
||||
mod parse_struct;
|
||||
pub mod utils;
|
||||
|
||||
pub use parse_enum::*;
|
||||
pub use parse_struct::*;
|
||||
@@ -0,0 +1,240 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type, should_skip_element,
|
||||
};
|
||||
|
||||
/// Parse a rust enum and returns a tuple of the enum name and the
|
||||
/// enum compiled to a typescript type
|
||||
pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
|
||||
let enum_name = element.ident.to_string();
|
||||
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
let mut enum_variants: Vec<String> = Vec::new();
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for variant in &element.variants {
|
||||
if should_skip_element(&variant.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parsed_variant = rename_option.transform(variant.ident.to_string());
|
||||
|
||||
match &variant.fields {
|
||||
syn::Fields::Named(field) => {
|
||||
parsed_variant = format!("{{\"{}\": {{ ", parsed_variant);
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
|
||||
for field in &field.named {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = rename_option.transform(get_field_name(field));
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(format!("{field_name}: {field_type}"));
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{parsed_variant}{} }}}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unnamed(field) => {
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
for field in &field.unnamed {
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(field_type);
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{{\"{parsed_variant}\": [{}]}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unit => {
|
||||
enum_variants.push(format!("\"{parsed_variant}\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let enum_type = format!(
|
||||
"export type {enum_name}{generics} = {};\n",
|
||||
enum_variants.join(" | ")
|
||||
);
|
||||
(enum_name, enum_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_a_simple_enum() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
Variant3
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_apply_rename_all_modifier() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_skips_a_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge,
|
||||
#[serde(skip)]
|
||||
SkipMe
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_struct_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
#[serde(skip)]
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Request {
|
||||
body: Bytes
|
||||
},
|
||||
Response{
|
||||
payload: Bytes
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_generic_type() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum<T> {
|
||||
Id,
|
||||
User {
|
||||
name: T,
|
||||
age: u32
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum<T> = \"Id\" | {\"User\": { name: T, age: number }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_tuple_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User(String, u32),
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": [string, number]};\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type,
|
||||
};
|
||||
|
||||
use super::utils::should_skip_element;
|
||||
|
||||
/// Parse a rust struct and returns a tuple of the struct name and the
|
||||
/// struct compiled to a typescript interface
|
||||
pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) {
|
||||
let struct_name = element.ident.to_string();
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
|
||||
let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n");
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for field in &element.fields {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = get_field_name(field);
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
|
||||
let field_name = rename_option.transform(field_name);
|
||||
|
||||
fields_as_string.push_str(&format!(" {field_name}: {field_type};\n"));
|
||||
}
|
||||
|
||||
fields_as_string.push_str("}\n");
|
||||
|
||||
(struct_name, fields_as_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct<'a, T, R>{
|
||||
field_1: &str,
|
||||
field2: i32,
|
||||
field3: Option<String>,
|
||||
field4: Vec<i32>,
|
||||
record: HashMap<&'a str, i32>,
|
||||
user: User,
|
||||
generic: T,
|
||||
generic2: HashMap<&mut str, R>,
|
||||
btree: BTreeMap<String, i32>,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct<T, R> {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record<string, number>;\n user: User;\n generic: T;\n generic2: Record<string, R>;\n btree: Record<string, number>;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_tuple_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
tuple: (i32, i32, String, User),
|
||||
|
||||
}"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n tuple: [number, number, string, User];\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct_with_no_generics() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field_1: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_1: string;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_camel_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_pascal_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_retrieve_the_serde_rename_option() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
|
||||
assert_eq!(rename_option, RenameSerdeOptions::PascalCase);
|
||||
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}"#;
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
assert_eq!(rename_option, RenameSerdeOptions::CamelCase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_identifies_skip_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(skip)]
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
#[serde(skip_serializing)]
|
||||
field_three: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_two: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_override_a_field_name() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(rename = "field_one")]
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_one: number;\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use convert_case::{Case, Casing};
|
||||
use std::str::FromStr;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{GenericArgument, GenericParam, PathArguments};
|
||||
|
||||
fn type_to_typescript(type_name: &str) -> &str {
|
||||
match type_name {
|
||||
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
|
||||
| "isize" | "usize" => "number",
|
||||
"str" | "String" | "char" => "string",
|
||||
"bool" => "boolean",
|
||||
_ => type_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_field_name(field: &syn::Field) -> String {
|
||||
let field_name: String = parse_serde_attribute(&field.attrs, "rename");
|
||||
|
||||
if !field_name.is_empty() {
|
||||
return field_name;
|
||||
}
|
||||
|
||||
if let Some(field) = field.ident.as_ref() {
|
||||
field.to_string()
|
||||
} else {
|
||||
String::from("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the struct generics and return them collected into a "<...>" string.
|
||||
/// If no generics are present, return an empty string.
|
||||
pub fn parse_generics_to_typescript_string(generics: Punctuated<GenericParam, Comma>) -> String {
|
||||
let generics = generics
|
||||
.iter()
|
||||
.map(|param| {
|
||||
if let syn::GenericParam::Type(type_param) = param {
|
||||
type_param.ident.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if !generics.is_empty() {
|
||||
return format!("<{}>", generics.join(", "));
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified
|
||||
/// attribute.
|
||||
pub fn parse_serde_attribute<T>(attrs: &[syn::Attribute], attribute_name: &str) -> T
|
||||
where
|
||||
T: Default + FromStr,
|
||||
{
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Expr>() {
|
||||
match meta {
|
||||
syn::Expr::Assign(assign) => {
|
||||
if let syn::Expr::Path(path) = *assign.left {
|
||||
if !path.path.is_ident(attribute_name) {
|
||||
return T::default();
|
||||
}
|
||||
}
|
||||
if let syn::Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(lit_str),
|
||||
..
|
||||
}) = *assign.right
|
||||
{
|
||||
return T::from_str(&lit_str.value()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
_ => return T::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T::default()
|
||||
}
|
||||
|
||||
/// Check if the element should be skipped based on the presence of
|
||||
/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes.
|
||||
pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Ident>() {
|
||||
if meta == "skip" || meta == "skip_serializing" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn rust_to_typescript_type(ty: &syn::Type) -> String {
|
||||
match ty {
|
||||
syn::Type::Tuple(tuple) => {
|
||||
let inner_types: Vec<String> =
|
||||
tuple.elems.iter().map(rust_to_typescript_type).collect();
|
||||
format!("[{}]", inner_types.join(", "))
|
||||
}
|
||||
syn::Type::Path(type_path) => {
|
||||
if let Some(last_segment) = type_path.path.segments.last() {
|
||||
let outer_type = last_segment.ident.to_string();
|
||||
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
||||
let inner_types: Vec<String> = args
|
||||
.args
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
if let GenericArgument::Type(inner_type) = arg {
|
||||
match inner_type {
|
||||
syn::Type::Path(inner_type_path) => {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
if let syn::Type::Path(inner_type_path) = &*reference.elem {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
} else {
|
||||
Some("unknown".to_string())
|
||||
}
|
||||
}
|
||||
_ => Some("unknown".to_string()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
match outer_type.as_str() {
|
||||
"Option" => {
|
||||
format!("{} | null", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"Vec" => {
|
||||
format!("{}[]", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"HashMap" | "BTreeMap" => {
|
||||
format!(
|
||||
"Record<{}, {}>",
|
||||
type_to_typescript(&inner_types[0]),
|
||||
type_to_typescript(&inner_types[1])
|
||||
)
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
type_to_typescript(&outer_type).to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
// Ignore lifetimes and treat references as their base type
|
||||
if let syn::Type::Path(type_path) = &*reference.elem {
|
||||
if let Some(base_type) = type_path.path.segments.last() {
|
||||
type_to_typescript(&base_type.ident.to_string()).to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// This enum matches serde's RenameRule enum
|
||||
#[derive(Debug, Eq, PartialEq, Default)]
|
||||
pub enum RenameSerdeOptions {
|
||||
#[default]
|
||||
None,
|
||||
LowerCase,
|
||||
UpperCase,
|
||||
PascalCase,
|
||||
CamelCase,
|
||||
SnakeCase,
|
||||
ScreamingSnakeCase,
|
||||
KebabCase,
|
||||
ScreamingKebabCase,
|
||||
}
|
||||
|
||||
impl FromStr for RenameSerdeOptions {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
match input {
|
||||
"lowercase" => Ok(Self::LowerCase),
|
||||
"UPPERCASE" => Ok(Self::UpperCase),
|
||||
"PascalCase" => Ok(Self::PascalCase),
|
||||
"camelCase" => Ok(Self::CamelCase),
|
||||
"snake_case" => Ok(Self::SnakeCase),
|
||||
"SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase),
|
||||
"kebab-case" => Ok(Self::KebabCase),
|
||||
"SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameSerdeOptions {
|
||||
pub fn transform(&self, input: String) -> String {
|
||||
match self {
|
||||
Self::LowerCase => input.to_lowercase(),
|
||||
Self::UpperCase => input.to_uppercase(),
|
||||
Self::CamelCase => input.to_case(Case::Camel),
|
||||
Self::PascalCase => input.to_case(Case::Pascal),
|
||||
_ => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use crate::typescript::FileTypes;
|
||||
use glob::glob;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, trace};
|
||||
use tuono_internal::tuono_println;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TypesJar {
|
||||
types: Vec<FileTypes>,
|
||||
should_generate_typescript_file: bool,
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
types: Vec::new(),
|
||||
should_generate_typescript_file: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
/// Removes all types from the jar that are
|
||||
/// present in the provided `file_path`.
|
||||
/// This function is triggered when a file is deleted
|
||||
pub fn remove_file(&mut self, file_path: PathBuf) {
|
||||
self.should_generate_typescript_file = true;
|
||||
self.types.retain(|ttype| ttype.file_path != file_path);
|
||||
}
|
||||
|
||||
pub fn refresh_file(&mut self, path: PathBuf) {
|
||||
if let Ok(file_str) = read_to_string(&path) {
|
||||
if file_str.contains(*TYPE_TRAIT) {
|
||||
if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) {
|
||||
if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) {
|
||||
// The new file exactly matches the old one
|
||||
trace!("File already exists in jar: {:?}", path);
|
||||
return;
|
||||
}
|
||||
trace!("Refreshing: {:?} type", ttype.types);
|
||||
|
||||
self.should_generate_typescript_file = true;
|
||||
self.remove_file(path);
|
||||
self.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", path);
|
||||
}
|
||||
} else {
|
||||
// Check if the file exist. In case it is it means that the user
|
||||
// removed the "Type" derived trait. Hence we have to remove it from
|
||||
// the jar
|
||||
self.remove_file(path.clone());
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_duplicate_types(&self) -> HashMap<String, (PathBuf, PathBuf)> {
|
||||
trace!("Checking for duplicated types");
|
||||
let mut duplicates: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
|
||||
let mut paths: Vec<&PathBuf> = Vec::new();
|
||||
let mut types: Vec<&Vec<String>> = Vec::new();
|
||||
|
||||
for file_types in self.types.iter() {
|
||||
paths.push(&file_types.file_path);
|
||||
types.push(&file_types.types);
|
||||
}
|
||||
|
||||
for i in 0..types.len() {
|
||||
for j in (i + 1)..types.len() {
|
||||
let types_i = types[i];
|
||||
let types_j = types[j];
|
||||
|
||||
for type_i in types_i {
|
||||
if types_j.contains(type_i) {
|
||||
duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duplicates
|
||||
}
|
||||
|
||||
fn log_duplicated_types(&self) {
|
||||
let duplicates = self.check_duplicate_types();
|
||||
let base_path = env::current_dir().unwrap_or_default();
|
||||
let base_path_str = base_path.to_string_lossy();
|
||||
|
||||
for (type_name, file_paths) in duplicates.iter() {
|
||||
let first_file_path = file_paths
|
||||
.0
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
let second_file_path = file_paths
|
||||
.1
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
|
||||
tuono_println!("Duplicate \"{}\" type found in files:\n", type_name);
|
||||
tuono_println!("- {}", first_file_path);
|
||||
tuono_println!("- {}\n", second_file_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the string containing all the typescript types
|
||||
/// found in the jar.
|
||||
fn generate_typescript(&self) -> String {
|
||||
self.log_duplicated_types();
|
||||
let mut typescript = String::from("declare module \"tuono/types\" {\n");
|
||||
for ttype in &self.types {
|
||||
typescript.push_str(&format!(
|
||||
"// START [{}]\n",
|
||||
ttype.file_path.to_string_lossy()
|
||||
));
|
||||
typescript.push_str(&ttype.types_as_string);
|
||||
typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy()));
|
||||
}
|
||||
typescript.push_str("}\n");
|
||||
typescript
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> {
|
||||
if !self.should_generate_typescript_file {
|
||||
trace!("No need to create typescript module file");
|
||||
return Ok(());
|
||||
}
|
||||
self.should_generate_typescript_file = false;
|
||||
trace!("Creating typescript module file");
|
||||
let typescript = self.generate_typescript();
|
||||
let typescript_file_path = base_path.join(".tuono").join("types.ts");
|
||||
std::fs::write(typescript_file_path, typescript)?;
|
||||
|
||||
trace!("Typescript module file created");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PathBuf> for TypesJar {
|
||||
/// Fill the TypesJar with all the Rust files found within
|
||||
/// the provided `base_path`.
|
||||
fn from(base_path: &PathBuf) -> Self {
|
||||
let mut jar = Self::new();
|
||||
|
||||
if let Some(path) = base_path.join("src/**/*.rs").to_str() {
|
||||
if let Ok(files) = glob(path) {
|
||||
files.for_each(|path| {
|
||||
let file_path = path.unwrap_or_default();
|
||||
if let Ok(file_str) = read_to_string(&file_path) {
|
||||
if !file_str.contains(*TYPE_TRAIT) {
|
||||
return;
|
||||
}
|
||||
if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) {
|
||||
jar.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", file_path);
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", file_path);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
error!("Failed to read glob pattern: {:?}", path);
|
||||
}
|
||||
}
|
||||
jar
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_finds_duplicate_types() {
|
||||
let file_type1 = FileTypes {
|
||||
file_path: PathBuf::from("src/types1.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type3"),
|
||||
],
|
||||
};
|
||||
let file_type2 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type4"),
|
||||
],
|
||||
};
|
||||
|
||||
let file_type3 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type3")],
|
||||
};
|
||||
|
||||
let mut jar = TypesJar::new();
|
||||
jar.types.push(file_type1);
|
||||
jar.types.push(file_type2);
|
||||
jar.types.push(file_type3);
|
||||
|
||||
let result = jar.check_duplicate_types();
|
||||
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type1".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type2".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type3".to_string())
|
||||
);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type4".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_removes_types() {
|
||||
let mut jar = TypesJar::new();
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_type = FileTypes {
|
||||
file_path: file_path.clone(),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type1")],
|
||||
};
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
//Force file generation to false
|
||||
jar.should_generate_typescript_file = false;
|
||||
|
||||
jar.types.push(file_type);
|
||||
jar.remove_file(file_path.clone());
|
||||
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
assert_eq!(jar.types.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use syn::{Attribute, Meta};
|
||||
|
||||
pub fn has_derive_type(attrs: &[Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if let Meta::List(meta_list) = &attr.meta {
|
||||
if meta_list.path.is_ident("derive") {
|
||||
for nested_meta in meta_list
|
||||
.parse_args_with(
|
||||
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Meta::Path(path) = nested_meta {
|
||||
if path.is_ident(&TYPE_TRAIT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use syn::{ItemEnum, ItemStruct, ItemType, parse_quote};
|
||||
|
||||
#[test]
|
||||
fn it_correctly_checks_if_derive_type_is_present() {
|
||||
let test_struct: ItemStruct = parse_quote! {
|
||||
#[derive(Type)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_struct.attrs));
|
||||
|
||||
let test_type: ItemType = parse_quote! {
|
||||
#[derive(Type)]
|
||||
type MyType = i32;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_type.attrs));
|
||||
|
||||
let test_enum: ItemEnum = parse_quote! {
|
||||
#[derive(Type)]
|
||||
enum MyEnunType {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_enum.attrs));
|
||||
|
||||
let test_struct_without_type: ItemStruct = parse_quote! {
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(!has_derive_type(&test_struct_without_type.attrs));
|
||||
|
||||
let multi_derived_trait: ItemStruct = parse_quote! {
|
||||
#[derive(Type, Serialize, Debug)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&multi_derived_trait.attrs));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::{Props, Response};
|
||||
pub use server::{Server, tuono_internal_init_v8_platform};
|
||||
pub use tuono_lib_macros::{api, handler};
|
||||
pub use tuono_lib_macros::{Type, api, handler};
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
|
||||
@@ -107,7 +107,7 @@ impl<'a> Payload<'a> {
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
let mut route_segments_collector: Vec<&str> = vec![];
|
||||
let mut route_segments_collector: Vec<&str> = Vec::new();
|
||||
|
||||
for i in 0..dyn_route_segments.len() {
|
||||
if dyn_route_segments[i].starts_with("[...") {
|
||||
|
||||
@@ -19,3 +19,13 @@ pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
pub fn api(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
api::api_core(args, item)
|
||||
}
|
||||
|
||||
/// Automatically generate typescript's types
|
||||
/// from Rust's structs, types and enums.
|
||||
///
|
||||
/// The types will be exported on the client side
|
||||
/// and it will be available from the `"tuono/types"` module.
|
||||
#[proc_macro_derive(Type)]
|
||||
pub fn derive_typescript_type(_: TokenStream) -> TokenStream {
|
||||
TokenStream::new()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
use tuono_lib::{Props, Request, Response, Type};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Type)]
|
||||
struct MyResponse<'a> {
|
||||
subtitle: &'a str,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { TuonoRouteProps } from 'tuono'
|
||||
import { Link } from 'tuono'
|
||||
|
||||
interface IndexProps {
|
||||
subtitle: string
|
||||
}
|
||||
import type { MyResponse } from 'tuono/types'
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
isLoading,
|
||||
}: TuonoRouteProps<IndexProps>): JSX.Element {
|
||||
}: TuonoRouteProps<MyResponse>): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <h1>Loading...</h1>
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user