use std::{
any::TypeId,
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fmt::Write,
fs::File,
io::{Seek, SeekFrom},
path::{Component, Path, PathBuf},
sync::{Mutex, OnceLock},
};
pub use error::ExportError;
use path::diff_paths;
pub(crate) use recursive_export::export_all_into;
use crate::TS;
mod error;
mod path;
static EXPORT_PATHS: OnceLock<Mutex<HashMap<PathBuf, HashSet<String>>>> = OnceLock::new();
fn get_export_paths<'a>() -> &'a Mutex<HashMap<PathBuf, HashSet<String>>> {
EXPORT_PATHS.get_or_init(Default::default)
}
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 TypeVisitor for Visit<'_> {
fn visit<T: TS + 'static + ?Sized>(&mut self) {
if self.error.is_some() || <T as crate::TS>::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 as crate::TS>::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 as crate::TS>::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 as crate::TS>::ident();
#[allow(unused_mut)]
let mut buffer = export_to_string::<T>()?;
#[cfg(feature = "format")]
{
use dprint_plugin_typescript::{
configuration::ConfigurationBuilder, format_text, FormatTextOptions,
};
let fmt_cfg = ConfigurationBuilder::new().deno().build();
let options = FormatTextOptions {
config: &fmt_cfg,
path: path.as_ref(),
text: buffer.clone(),
extension: None,
external_formatter: None,
};
if let Some(formatted) =
format_text(options).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 lock = &mut get_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 import_lines = original_header
.lines()
.skip(1)
.chain(new_header.lines().skip(1))
.map(|line| {
let (import, from) = line.split_once(" from ").unwrap();
let path = from.trim_start_matches('"').trim_end_matches(['"', ';']);
let types = import
.trim_start_matches("import type { ")
.trim_end_matches(" }")
.split(", ");
(path, types)
});
let mut imports_map: BTreeMap<&str, BTreeSet<&str>> = Default::default();
for (path, types) in import_lines {
let entry = imports_map.entry(path).or_default();
for ty in types {
entry.insert(ty);
}
}
let mut imports = String::new();
for (path, types) in imports_map {
imports.push_str("import type { ");
let mut types = types.iter().peekable();
while let Some(ty) = types.next() {
imports.push_str(ty);
if types.peek().is_some() {
imports.push_str(", ");
}
}
imports.push_str(" } from \"");
imports.push_str(path);
imports.push_str("\";\n");
}
let capacity = imports.len() + original_decls.len() + new_decl.len() + 2;
let mut buffer = String::with_capacity(capacity);
buffer.push_str(&imports);
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 as crate::TS>::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)),
}
}
pub(crate) fn import_extension() -> Result<&'static str, ExportError> {
match std::env::var("TS_RS_IMPORT_EXTENSION").as_deref() {
Err(_) if cfg!(feature = "import-esm") => {
const YELLOW_TEXT: &str = "\x1b[0;33m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[1m";
let mut stderr = std::io::stderr();
_ = std::io::Write::write_all(&mut stderr, format!("{YELLOW_TEXT}{BOLD}Warning:{RESET} The \"import-esm\" feature is deprecated and will be removed in the next major release. Set the TS_RS_IMPORT_EXTENSION environment variable to \"js\" instead.\n").as_bytes());
Ok(".js")
}
Err(_) => Ok(""),
Ok("js") => Ok(".js"),
Ok("ts") => Ok(".ts"),
Ok(_) => Err(ExportError::InvalidImportExtension),
}
}
fn generate_decl<T: TS + ?Sized>(out: &mut String) {
if let Some(docs) = <T as crate::TS>::docs() {
out.push_str(&docs);
}
out.push_str("export ");
out.push_str(&<T as crate::TS>::decl());
}
fn generate_imports<T: TS + ?Sized + 'static>(
out: &mut String,
out_dir: impl AsRef<Path>,
) -> Result<(), ExportError> {
let path = <T as crate::TS>::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 as crate::TS>::dependencies();
let deduplicated_deps = deps
.iter()
.filter(|dep| dep.type_id != TypeId::of::<T>())
.map(|dep| (&dep.ts_name, dep))
.collect::<BTreeMap<_, _>>();
let mut imports: BTreeMap<String, BTreeSet<&str>> = Default::default();
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;
}
imports.entry(rel_path).or_default().insert(&dep.ts_name);
}
for (path, types) in imports {
write!(out, "import type {{ ")?;
let mut types = types.iter().peekable();
while let Some(ty) = types.next() {
write!(out, "{ty}")?;
if types.peek().is_some() {
write!(out, ", ")?;
}
}
writeln!(out, r#" }} from "{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 = path.trim_end_matches(".ts");
let import_extension = import_extension()?;
Ok(format!("{path}{import_extension}"))
}