use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fmt,
ops::Deref,
panic::Location,
path::{Path, PathBuf},
sync::Arc,
};
use specta::{
ResolvedTypes, Types,
datatype::{DataType, NamedDataType, Reference},
};
use crate::{Branded, Error, primitives, references};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Layout {
Namespaces,
Files,
ModulePrefixedName,
#[default]
FlatFile,
}
impl fmt::Display for Layout {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct RuntimeFn(Arc<dyn Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync>);
impl fmt::Debug for RuntimeFn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "RuntimeFn({:p})", self.0)
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct BrandedTypeImpl(
pub(crate) Arc<
dyn for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
+ Send
+ Sync,
>,
);
impl fmt::Debug for BrandedTypeImpl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "BrandedTypeImpl({:p})", self.0)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Exporter {
pub header: Cow<'static, str>,
framework_runtime: Option<RuntimeFn>,
pub(crate) branded_type_impl: Option<BrandedTypeImpl>,
framework_prelude: Cow<'static, str>,
pub layout: Layout,
pub(crate) jsdoc: bool,
}
impl Exporter {
pub(crate) fn default() -> Exporter {
Exporter {
header: Cow::Borrowed(""),
framework_runtime: None,
branded_type_impl: None,
framework_prelude: Cow::Borrowed(
"// This file has been generated by Specta. Do not edit this file manually.",
),
layout: Default::default(),
jsdoc: false,
}
}
pub fn framework_prelude(mut self, prelude: impl Into<Cow<'static, str>>) -> Self {
self.framework_prelude = prelude.into();
self
}
pub fn framework_runtime(
mut self,
builder: impl Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync + 'static,
) -> Self {
self.framework_runtime = Some(RuntimeFn(Arc::new(builder)));
self
}
pub fn branded_type_impl(
mut self,
builder: impl for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
+ Send
+ Sync
+ 'static,
) -> Self {
self.branded_type_impl = Some(BrandedTypeImpl(Arc::new(builder)));
self
}
pub fn header(mut self, header: impl Into<Cow<'static, str>>) -> Self {
self.header = header.into();
self
}
pub fn layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
pub fn export(&self, resolved_types: &ResolvedTypes) -> Result<String, Error> {
let types = resolved_types.as_types();
if let Layout::Files = self.layout {
return Err(Error::unable_to_export(self.layout));
}
if let Layout::Namespaces = self.layout
&& self.jsdoc
{
return Err(Error::unable_to_export(self.layout));
}
let mut out = render_file_header(self)?;
let mut has_manually_exported_user_types = false;
let mut runtime = Ok(Cow::default());
if let Some(framework_runtime) = &self.framework_runtime {
runtime = (framework_runtime.0)(FrameworkExporter {
exporter: self,
has_manually_exported_user_types: &mut has_manually_exported_user_types,
files_root_types: "",
types: resolved_types,
});
}
let runtime = runtime?;
if !runtime.is_empty() {
out += "\n";
}
out += &runtime;
if !runtime.is_empty() {
out += "\n";
}
if !has_manually_exported_user_types {
render_types(&mut out, self, types, "")?;
}
Ok(out)
}
pub fn export_to(
&self,
path: impl AsRef<Path>,
resolved_types: &ResolvedTypes,
) -> Result<(), Error> {
let types = resolved_types.as_types();
let path = path.as_ref();
if self.layout != Layout::Files {
let result = self.export(resolved_types)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
};
std::fs::write(path, result)?;
return Ok(());
}
fn export(
exporter: &Exporter,
types: &Types,
module: &mut Module,
s: &mut String,
path: &Path,
files: &mut HashMap<PathBuf, String>,
) -> Result<bool, Error> {
module.types.sort_by(|a, b| {
a.name()
.cmp(b.name())
.then(a.module_path().cmp(b.module_path()))
.then(a.location().cmp(&b.location()))
});
let (rendered_types_result, referenced_types) =
references::with_module_path(module.module_path.as_ref(), || {
references::collect_references(|| {
let mut rendered = String::new();
let exports = render_flat_types(
&mut rendered,
exporter,
types,
module.types.iter().copied(),
"",
)?;
Ok::<_, Error>((rendered, exports))
})
});
let (rendered_types, exports) = rendered_types_result?;
let import_paths = referenced_types
.into_iter()
.filter_map(|r| {
r.get(types)
.map(|ndt| ndt.module_path().as_ref().to_string())
})
.filter(|module_path| module_path != module.module_path.as_ref())
.collect::<BTreeSet<_>>();
if !import_paths.is_empty() {
s.push('\n');
s.push_str(&module_import_block(
exporter,
module.module_path.as_ref(),
&import_paths,
));
}
if !import_paths.is_empty() && !rendered_types.is_empty() {
s.push('\n');
}
s.push_str(&rendered_types);
for (name, module) in &mut module.children {
if module.types.is_empty() && module.children.is_empty() {
continue;
}
let mut path = path.join(name);
let mut out = render_file_header(exporter)?;
let has_types = export(exporter, types, module, &mut out, &path, files)?;
if has_types {
path.set_extension(if exporter.jsdoc { "js" } else { "ts" });
files.insert(path, out);
}
}
Ok(!exports.is_empty())
}
let mut files = HashMap::new();
let mut runtime_path = path.join("index");
runtime_path.set_extension(if self.jsdoc { "js" } else { "ts" });
let mut root_types = String::new();
export(
self,
types,
&mut build_module_graph(types),
&mut root_types,
path,
&mut files,
)?;
{
let mut has_manually_exported_user_types = false;
let mut runtime = Cow::default();
let mut runtime_references = HashSet::new();
if let Some(framework_runtime) = &self.framework_runtime {
let (runtime_result, referenced_types) = references::with_module_path("", || {
references::collect_references(|| {
(framework_runtime.0)(FrameworkExporter {
exporter: self,
has_manually_exported_user_types: &mut has_manually_exported_user_types,
files_root_types: &root_types,
types: resolved_types,
})
})
});
runtime = runtime_result?;
runtime_references = referenced_types;
}
let should_export_user_types =
!has_manually_exported_user_types && !root_types.is_empty();
if !runtime.is_empty() || should_export_user_types {
files.insert(runtime_path, {
let mut out = render_file_header(self)?;
let mut body = String::new();
if !runtime.is_empty() {
body.push_str(&runtime);
}
if should_export_user_types {
if !body.is_empty() {
body.push('\n');
}
body.push_str(&root_types);
}
let import_paths = runtime_references
.into_iter()
.filter_map(|r| {
r.get(types)
.map(|ndt| ndt.module_path().as_ref().to_string())
})
.filter(|module_path| !module_path.is_empty())
.collect::<BTreeSet<_>>();
let import_paths = import_paths
.into_iter()
.filter(|module_path| {
!body.contains(&module_import_statement(self, "", module_path))
})
.collect::<BTreeSet<_>>();
if !import_paths.is_empty() {
out.push('\n');
out.push_str(&module_import_block(self, "", &import_paths));
}
if !body.is_empty() {
out.push('\n');
if !import_paths.is_empty() {
out.push('\n');
}
out.push_str(&body);
}
out
});
}
}
match path.metadata() {
Ok(meta) if !meta.is_dir() => std::fs::remove_file(path).or_else(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(Error::remove_file(path.to_path_buf(), source))
}
})?,
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(Error::metadata(path.to_path_buf(), source));
}
}
for (path, content) in &files {
path.parent().map(std::fs::create_dir_all).transpose()?;
std::fs::write(path, content)?;
}
cleanup_stale_files(path, &files)?;
Ok(())
}
}
impl AsRef<Exporter> for Exporter {
fn as_ref(&self) -> &Exporter {
self
}
}
impl AsMut<Exporter> for Exporter {
fn as_mut(&mut self) -> &mut Exporter {
self
}
}
pub struct BrandedTypeExporter<'a> {
pub(crate) exporter: &'a Exporter,
pub types: &'a ResolvedTypes,
}
impl fmt::Debug for BrandedTypeExporter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.exporter.fmt(f)
}
}
impl AsRef<Exporter> for BrandedTypeExporter<'_> {
fn as_ref(&self) -> &Exporter {
self
}
}
impl Deref for BrandedTypeExporter<'_> {
type Target = Exporter;
fn deref(&self) -> &Self::Target {
self.exporter
}
}
impl BrandedTypeExporter<'_> {
pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
primitives::inline(self, self.types, dt)
}
pub fn reference(&self, r: &Reference) -> Result<String, Error> {
primitives::reference(self, self.types, r)
}
}
pub struct FrameworkExporter<'a> {
exporter: &'a Exporter,
has_manually_exported_user_types: &'a mut bool,
files_root_types: &'a str,
pub types: &'a ResolvedTypes,
}
impl fmt::Debug for FrameworkExporter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.exporter.fmt(f)
}
}
impl AsRef<Exporter> for FrameworkExporter<'_> {
fn as_ref(&self) -> &Exporter {
self
}
}
impl Deref for FrameworkExporter<'_> {
type Target = Exporter;
fn deref(&self) -> &Self::Target {
self.exporter
}
}
impl FrameworkExporter<'_> {
pub fn render_types(&mut self) -> Result<Cow<'static, str>, Error> {
let mut s = String::new();
render_types(
&mut s,
self.exporter,
self.types.as_types(),
self.files_root_types,
)?;
*self.has_manually_exported_user_types = true;
Ok(Cow::Owned(s))
}
pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
primitives::inline(self, self.types, dt)
}
pub fn reference(&self, r: &Reference) -> Result<String, Error> {
primitives::reference(self, self.types, r)
}
pub fn export<'a>(
&self,
ndts: impl Iterator<Item = &'a NamedDataType>,
indent: &'a str,
) -> Result<String, Error> {
primitives::export(self, self.types, ndts, indent)
}
}
struct Module<'a> {
types: Vec<&'a NamedDataType>,
children: BTreeMap<&'a str, Module<'a>>,
module_path: Cow<'static, str>,
}
fn build_module_graph(types: &Types) -> Module<'_> {
types.into_unsorted_iter().fold(
Module {
types: Default::default(),
children: Default::default(),
module_path: Default::default(),
},
|mut ns, ndt| {
let path = ndt.module_path();
if path.is_empty() {
ns.types.push(ndt);
} else {
let mut current = &mut ns;
let mut current_path = String::new();
for segment in path.split("::") {
if !current_path.is_empty() {
current_path.push_str("::");
}
current_path.push_str(segment);
current = current.children.entry(segment).or_insert_with(|| Module {
types: Default::default(),
children: Default::default(),
module_path: current_path.clone().into(),
});
}
current.types.push(ndt);
}
ns
},
)
}
fn render_file_header(exporter: &Exporter) -> Result<String, Error> {
let mut out = exporter.header.to_string();
if !exporter.header.is_empty() {
out += "\n";
}
out += &exporter.framework_prelude;
if !exporter.framework_prelude.is_empty() {
out += "\n";
}
Ok(out)
}
fn render_types(
s: &mut String,
exporter: &Exporter,
types: &Types,
files_user_types: &str,
) -> Result<(), Error> {
match exporter.layout {
Layout::Namespaces => {
fn has_renderable_content(module: &Module<'_>, types: &Types) -> bool {
module.types.iter().any(|ndt| ndt.requires_reference(types))
|| module
.children
.values()
.any(|child| has_renderable_content(child, types))
}
fn export<'a>(
exporter: &Exporter,
types: &Types,
s: &mut String,
module: impl ExactSizeIterator<Item = (&'a &'a str, &'a mut Module<'a>)>,
depth: usize,
) -> Result<(), Error> {
let namespace_indent = "\t".repeat(depth);
let content_indent = "\t".repeat(depth + 1);
for (name, module) in module {
if !has_renderable_content(module, types) {
continue;
}
s.push('\n');
s.push_str(&namespace_indent);
if depth != 0 && *name != "$specta$" {
s.push_str("export ");
}
s.push_str("namespace ");
s.push_str(name);
s.push_str(" {\n");
module.types.sort_by(|a, b| {
a.name()
.cmp(b.name())
.then(a.module_path().cmp(b.module_path()))
.then(a.location().cmp(&b.location()))
});
render_flat_types(
s,
exporter,
types,
module.types.iter().copied(),
&content_indent,
)?;
export(exporter, types, s, module.children.iter_mut(), depth + 1)?;
s.push_str(&namespace_indent);
s.push_str("}\n");
}
Ok(())
}
let mut module = build_module_graph(types);
let reexports = {
let mut reexports = String::new();
for name in module
.children
.iter()
.filter_map(|(name, module)| {
has_renderable_content(module, types).then_some(*name)
})
.chain(
module
.types
.iter()
.filter(|ndt| ndt.requires_reference(types))
.map(|ndt| ndt.name().as_ref()),
)
{
reexports.push_str("export import ");
reexports.push_str(name);
reexports.push_str(" = $s$.");
reexports.push_str(name);
reexports.push_str(";\n");
}
reexports
};
export(exporter, types, s, [(&"$s$", &mut module)].into_iter(), 0)?;
s.push_str(&reexports);
}
Layout::ModulePrefixedName | Layout::FlatFile => {
render_flat_types(s, exporter, types, types.into_sorted_iter(), "")?;
}
Layout::Files => {
if !files_user_types.is_empty() {
s.push_str(files_user_types);
}
}
}
Ok(())
}
fn render_flat_types<'a>(
s: &mut String,
exporter: &Exporter,
types: &Types,
ndts: impl ExactSizeIterator<Item = &'a NamedDataType>,
indent: &str,
) -> Result<HashMap<String, Location<'static>>, Error> {
let mut exports = HashMap::with_capacity(ndts.len());
let ndts = ndts
.filter(|ndt| ndt.requires_reference(types))
.map(|ndt| {
let export_name = exported_type_name(exporter, ndt);
if let Some(other) = exports.insert(export_name.to_string(), ndt.location()) {
return Err(Error::duplicate_type_name(
export_name,
ndt.location(),
other,
));
}
Ok(ndt)
})
.collect::<Result<Vec<_>, _>>()?;
primitives::export_internal(s, exporter, types, ndts.into_iter(), indent)?;
Ok(exports)
}
fn collect_existing_files(root: &Path) -> Result<HashSet<PathBuf>, Error> {
if !root.exists() {
return Ok(HashSet::new());
}
let mut files = HashSet::new();
let entries =
std::fs::read_dir(root).map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
for entry in entries {
let entry = entry.map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|source| Error::metadata(path.clone(), source))?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
files.extend(collect_existing_files(&path)?);
} else if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
files.insert(path);
}
}
Ok(files)
}
fn is_generated_specta_file(path: &Path) -> Result<bool, Error> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(contents.contains("generated by Specta")),
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => Ok(false),
Err(source) => Err(Error::from(source)),
}
}
fn remove_empty_dirs(path: &Path, root: &Path) -> Result<(), Error> {
let entries =
std::fs::read_dir(path).map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
for entry in entries {
let entry = entry.map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
let entry_path = entry.path();
let file_type = entry
.file_type()
.map_err(|source| Error::metadata(entry_path.clone(), source))?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
remove_empty_dirs(&entry_path, root)?;
}
}
let is_empty = path
.read_dir()
.map_err(|source| Error::read_dir(path.to_path_buf(), source))?
.next()
.is_none();
if path != root && is_empty {
match std::fs::remove_dir(path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(Error::remove_dir(path.to_path_buf(), source));
}
}
}
Ok(())
}
fn cleanup_stale_files(root: &Path, current_files: &HashMap<PathBuf, String>) -> Result<(), Error> {
for path in collect_existing_files(root)? {
if current_files.contains_key(&path) || !is_generated_specta_file(&path)? {
continue;
}
std::fs::remove_file(&path).or_else(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(Error::remove_file(path.clone(), source))
}
})?;
}
remove_empty_dirs(root, root)?;
Ok(())
}
fn exported_type_name(exporter: &Exporter, ndt: &NamedDataType) -> Cow<'static, str> {
match exporter.layout {
Layout::ModulePrefixedName => {
let mut s = ndt.module_path().split("::").collect::<Vec<_>>().join("_");
s.push('_');
s.push_str(ndt.name());
Cow::Owned(s)
}
_ => ndt.name().clone(),
}
}
pub(crate) fn module_alias(module_path: &str) -> String {
if module_path.is_empty() {
"$root".to_string()
} else {
module_path.split("::").collect::<Vec<_>>().join("$")
}
}
fn module_import_statement(
exporter: &Exporter,
from_module_path: &str,
to_module_path: &str,
) -> String {
let import_keyword = if exporter.jsdoc {
"import"
} else {
"import type"
};
format!(
"{} * as {} from \"{}\";",
import_keyword,
module_alias(to_module_path),
module_import_path(from_module_path, to_module_path)
)
}
fn module_import_block(
exporter: &Exporter,
from_module_path: &str,
import_paths: &BTreeSet<String>,
) -> String {
if exporter.jsdoc {
let mut out = String::from("/**\n");
for module_path in import_paths {
out.push_str(" * @typedef {import(\"");
out.push_str(&module_import_path(from_module_path, module_path));
out.push_str("\")} ");
out.push_str(&module_alias(module_path));
out.push('\n');
}
out.push_str(" */");
out
} else {
import_paths
.iter()
.map(|module_path| module_import_statement(exporter, from_module_path, module_path))
.collect::<Vec<_>>()
.join("\n")
}
}
fn module_import_path(from_module_path: &str, to_module_path: &str) -> String {
fn module_file_segments(module_path: &str) -> Vec<&str> {
if module_path.is_empty() {
vec!["index"]
} else {
module_path.split("::").collect()
}
}
let from_file_segments = module_file_segments(from_module_path);
let from_dir_segments = &from_file_segments[..from_file_segments.len() - 1];
let to_file_segments = module_file_segments(to_module_path);
let shared = from_dir_segments
.iter()
.zip(to_file_segments.iter())
.take_while(|(a, b)| a == b)
.count();
let mut relative_parts = Vec::new();
relative_parts.extend(std::iter::repeat_n(
"..",
from_dir_segments.len().saturating_sub(shared),
));
relative_parts.extend(to_file_segments.iter().skip(shared).copied());
if relative_parts
.first()
.is_none_or(|v| *v != "." && *v != "..")
{
relative_parts.insert(0, ".");
}
relative_parts.join("/")
}