use std::{
any::TypeId,
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fmt::Write,
fs::File,
io::{Seek, SeekFrom},
path::{Component, Path, PathBuf},
sync::Mutex,
};
pub use error::ExportError;
use lazy_static::lazy_static;
use path::diff_paths;
pub(crate) use recursive_export::export_all_into;
use crate::TS;
mod error;
mod path;
lazy_static! {
static ref EXPORT_PATHS: Mutex<HashMap<PathBuf, HashSet<String>>> = Mutex::new(HashMap::new());
}
const NOTE: &str = "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n";
mod recursive_export {
use std::{any::TypeId, collections::HashSet, path::Path};
use super::export_into;
use crate::{ExportError, TypeVisitor, TS};
pub(crate) fn export_all_into<T: TS + ?Sized + 'static>(
out_dir: impl AsRef<Path>,
) -> Result<(), ExportError> {
let mut seen = HashSet::new();
export_recursive::<T>(&mut seen, out_dir)
}
struct Visit<'a> {
seen: &'a mut HashSet<TypeId>,
out_dir: &'a Path,
error: Option<ExportError>,
}
impl<'a> TypeVisitor for Visit<'a> {
fn visit<T: TS + 'static + ?Sized>(&mut self) {
if self.error.is_some() || T::output_path().is_none() {
return;
}
self.error = export_recursive::<T>(self.seen, self.out_dir).err();
}
}
fn export_recursive<T: TS + ?Sized + 'static>(
seen: &mut HashSet<TypeId>,
out_dir: impl AsRef<Path>,
) -> Result<(), ExportError> {
if !seen.insert(TypeId::of::<T>()) {
return Ok(());
}
let out_dir = out_dir.as_ref();
export_into::<T>(out_dir)?;
let mut visitor = Visit {
seen,
out_dir,
error: None,
};
T::visit_dependencies(&mut visitor);
if let Some(e) = visitor.error {
Err(e)
} else {
Ok(())
}
}
}
pub(crate) fn export_into<T: TS + ?Sized + 'static>(
out_dir: impl AsRef<Path>,
) -> Result<(), ExportError> {
let path = T::output_path()
.ok_or_else(std::any::type_name::<T>)
.map_err(ExportError::CannotBeExported)?;
let path = out_dir.as_ref().join(path);
export_to::<T, _>(path::absolute(path)?)
}
pub(crate) fn export_to<T: TS + ?Sized + 'static, P: AsRef<Path>>(
path: P,
) -> Result<(), ExportError> {
let path = path.as_ref().to_owned();
let type_name = T::ident();
#[allow(unused_mut)]
let mut buffer = export_to_string::<T>()?;
#[cfg(feature = "format")]
{
use dprint_plugin_typescript::{configuration::ConfigurationBuilder, format_text};
let fmt_cfg = ConfigurationBuilder::new().deno().build();
if let Some(formatted) = format_text(path.as_ref(), &buffer, &fmt_cfg)
.map_err(|e| ExportError::Formatting(e.to_string()))?
{
buffer = formatted;
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
export_and_merge(path, type_name, buffer)?;
Ok(())
}
fn export_and_merge(
path: PathBuf,
type_name: String,
generated_type: String,
) -> Result<(), ExportError> {
use std::io::{Read, Write};
let mut lock = EXPORT_PATHS.lock().unwrap();
let Some(entry) = lock.get_mut(&path) else {
let mut file = File::create(&path)?;
file.write_all(generated_type.as_bytes())?;
file.sync_all()?;
let mut set = HashSet::new();
set.insert(type_name);
lock.insert(path, set);
return Ok(());
};
if entry.contains(&type_name) {
return Ok(());
}
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)?;
let file_len = file.metadata()?.len();
let mut original_contents = String::with_capacity(file_len as usize);
file.read_to_string(&mut original_contents)?;
let buffer = merge(original_contents, generated_type);
file.seek(SeekFrom::Start(NOTE.len() as u64))?;
file.write_all(buffer.as_bytes())?;
file.sync_all()?;
entry.insert(type_name);
Ok(())
}
const HEADER_ERROR_MESSAGE: &str = "The generated strings must have their NOTE and imports separated from their type declarations by a new line";
const DECLARATION_START: &str = "export type ";
fn merge(original_contents: String, new_contents: String) -> String {
let (original_header, original_decls) = original_contents
.split_once("\n\n")
.expect(HEADER_ERROR_MESSAGE);
let (new_header, new_decl) = new_contents.split_once("\n\n").expect(HEADER_ERROR_MESSAGE);
let imports = original_header
.lines()
.skip(1)
.chain(new_header.lines().skip(1))
.collect::<BTreeSet<_>>();
let import_len = imports.iter().map(|&x| x.len()).sum::<usize>() + imports.len();
let capacity = import_len + original_decls.len() + new_decl.len() + 2;
let mut buffer = String::with_capacity(capacity);
for import in imports {
buffer.push_str(import);
buffer.push('\n')
}
let new_decl = new_decl.trim_matches('\n');
let new_decl_name = new_decl
.split(DECLARATION_START)
.last()
.unwrap()
.split_whitespace()
.next()
.unwrap();
let original_decls = original_decls.split("\n\n").map(|x| x.trim_matches('\n'));
let mut inserted = false;
for decl in original_decls {
let decl_name = decl
.split(DECLARATION_START)
.last()
.unwrap()
.split_whitespace()
.next()
.unwrap();
if inserted || decl_name < new_decl_name {
buffer.push('\n');
buffer.push_str(decl);
buffer.push('\n');
} else {
buffer.push('\n');
buffer.push_str(new_decl);
buffer.push('\n');
buffer.push('\n');
buffer.push_str(decl);
buffer.push('\n');
inserted = true;
}
}
if !inserted {
buffer.push('\n');
buffer.push_str(new_decl);
buffer.push('\n');
}
buffer
}
pub(crate) fn export_to_string<T: TS + ?Sized + 'static>() -> Result<String, ExportError> {
let mut buffer = String::with_capacity(1024);
buffer.push_str(NOTE);
generate_imports::<T::WithoutGenerics>(&mut buffer, default_out_dir())?;
generate_decl::<T>(&mut buffer);
buffer.push('\n');
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)),
}
}
fn generate_decl<T: TS + ?Sized>(out: &mut String) {
let docs = &T::DOCS;
if let Some(docs) = docs {
out.push_str(docs);
}
out.push_str("export ");
out.push_str(&T::decl());
}
fn generate_imports<T: TS + ?Sized + 'static>(
out: &mut String,
out_dir: impl AsRef<Path>,
) -> Result<(), ExportError> {
let path = T::output_path()
.ok_or_else(std::any::type_name::<T>)
.map(|x| out_dir.as_ref().join(x))
.map_err(ExportError::CannotBeExported)?;
let deps = T::dependencies();
let deduplicated_deps = deps
.iter()
.filter(|dep| dep.type_id != TypeId::of::<T>())
.map(|dep| (&dep.ts_name, dep))
.collect::<BTreeMap<_, _>>();
for (_, dep) in deduplicated_deps {
let dep_path = out_dir.as_ref().join(dep.output_path);
let rel_path = import_path(&path, &dep_path)?;
let is_same_file = path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(|x| x.trim_end_matches(".ts"))
.map(|x| format!("./{x}"))
.map(|x| x == rel_path.trim_end_matches(".js"))
.unwrap_or(false);
if is_same_file {
continue;
}
writeln!(
out,
r#"import type {{ {} }} from "{}";"#,
&dep.ts_name, rel_path
)?;
}
writeln!(out)?;
Ok(())
}
fn import_path(from: &Path, import: &Path) -> Result<String, ExportError> {
let rel_path = diff_paths(import, from.parent().unwrap())?;
let str_path = match rel_path.components().next() {
Some(Component::Normal(_)) => {
format!("./{}", rel_path.to_string_lossy())
}
_ => rel_path.to_string_lossy().into(),
};
let path = if cfg!(target_os = "windows") {
str_path.replace('\\', "/")
} else {
str_path
};
let path_without_extension = path.trim_end_matches(".ts");
Ok(if cfg!(feature = "import-esm") {
format!("{}.js", path_without_extension)
} else {
path_without_extension.to_owned()
})
}