use std::{
borrow::Cow,
collections::BTreeSet,
fmt::{self, Write},
};
use specta::{
Types,
datatype::{
DataType, Deprecated, Enum, Field, Fields, GenericReference, Reference, Struct, Tuple,
Variant,
},
};
use crate::{Error, Exporter, reserved_names::RESERVED_TYPE_NAMES};
#[derive(Clone, Debug)]
pub(crate) enum PathItem {
Field(Cow<'static, str>),
Variant(Cow<'static, str>),
}
#[derive(Clone)]
pub(crate) struct ExportContext<'a> {
pub(crate) cfg: &'a Exporter,
pub(crate) path: Vec<PathItem>,
}
impl ExportContext<'_> {
pub(crate) fn with(&self, item: PathItem) -> Self {
Self {
path: self.path.iter().cloned().chain([item]).collect(),
..*self
}
}
pub(crate) fn export_path(&self) -> ExportPath {
ExportPath::new(&self.path)
}
}
pub struct ExportPath(String);
impl ExportPath {
pub(crate) fn new(path: &[PathItem]) -> Self {
let mut s = String::new();
let mut path = path.iter().peekable();
while let Some(item) = path.next() {
s.push_str(match item {
PathItem::Field(v) => v,
PathItem::Variant(v) => v,
});
if let Some(next) = path.peek() {
s.push_str(match next {
PathItem::Field(_) => ".",
PathItem::Variant(_) => "::",
});
} else {
break;
}
}
Self(s)
}
}
impl PartialEq for ExportPath {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl fmt::Debug for ExportPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for ExportPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[allow(missing_docs)]
pub(crate) type Result<T> = std::result::Result<T, Error>;
pub(crate) type Output = Result<String>;
#[allow(clippy::ptr_arg)]
fn inner_comments(
deprecated: Option<&Deprecated>,
docs: &str,
other: String,
start_with_newline: bool,
prefix: &str,
single_line_comment: bool,
) -> String {
let mut comments = String::new();
js_doc(&mut comments, docs, deprecated, single_line_comment);
if comments.is_empty() {
return other;
}
let mut out = String::new();
if start_with_newline {
out.push('\n');
}
for line in comments.lines() {
out.push_str(prefix);
out.push_str(line);
out.push('\n');
}
out.push_str(&other);
out
}
pub(crate) fn datatype_inner(
ctx: ExportContext,
typ: &DataType,
types: &Types,
s: &mut String,
generics: &[(GenericReference, DataType)],
) -> Result<()> {
crate::primitives::datatype(s, ctx.cfg, types, typ, vec![], None, "", generics)
}
fn unnamed_fields_datatype(
ctx: ExportContext,
fields: &[(&Field, &DataType)],
types: &Types,
s: &mut String,
prefix: &str,
generics: &[(GenericReference, DataType)],
force_inline: bool,
) -> Result<()> {
match fields {
[(field, ty)] => {
let mut v = String::new();
crate::primitives::datatype_with_inline_attr(
&mut v,
ctx.cfg,
types,
ty,
vec![],
None,
"",
generics,
force_inline || field.inline(),
)?;
s.push_str(&inner_comments(
field.deprecated(),
field.docs(),
v,
true,
prefix,
!ctx.cfg.jsdoc,
));
}
fields => {
s.push('[');
for (i, (field, ty)) in fields.iter().enumerate() {
if i != 0 {
s.push_str(", ");
}
let mut v = String::new();
crate::primitives::datatype_with_inline_attr(
&mut v,
ctx.cfg,
types,
ty,
vec![],
None,
"",
generics,
force_inline || field.inline(),
)?;
s.push_str(&inner_comments(
field.deprecated(),
field.docs(),
v,
true,
prefix,
!ctx.cfg.jsdoc,
));
}
s.push(']');
}
}
Ok(())
}
pub(crate) fn tuple_datatype(
ctx: ExportContext,
tuple: &Tuple,
types: &Types,
generics: &[(GenericReference, DataType)],
) -> Output {
match &tuple.elements() {
[] => Ok(NULL.to_string()),
tys => Ok(format!(
"[{}]",
tys.iter()
.map(|v| {
let mut s = String::new();
datatype_inner(ctx.clone(), v, types, &mut s, generics).map(|_| s)
})
.collect::<Result<Vec<_>>>()?
.join(", ")
)),
}
}
pub(crate) fn struct_datatype(
ctx: ExportContext,
_parent_name: Option<&str>,
strct: &Struct,
types: &Types,
s: &mut String,
prefix: &str,
generics: &[(GenericReference, DataType)],
) -> Result<()> {
match &strct.fields() {
Fields::Unit => s.push_str(NULL),
Fields::Unnamed(unnamed) => unnamed_fields_datatype(
ctx,
&unnamed
.fields()
.iter()
.filter_map(|field| field.ty().map(|ty| (field, ty)))
.collect::<Vec<_>>(),
types,
s,
prefix,
generics,
false,
)?,
Fields::Named(named) => {
let fields = named
.fields()
.iter()
.filter_map(|(name, field)| field.ty().map(|ty| (name, (field, ty))))
.collect::<Vec<_>>();
if fields.is_empty() {
write!(s, "Record<{STRING}, {NEVER}>")?;
return Ok(());
}
let (flattened, non_flattened): (Vec<_>, Vec<_>) =
fields.iter().partition(|(_, (f, _))| f.flatten());
let mut flattened_sections = flattened
.into_iter()
.map(|(_key, (field, ty))| {
let mut s = String::new();
crate::primitives::datatype_with_inline_attr(
&mut s,
ctx.cfg,
types,
ty,
vec![],
None,
"",
generics,
field.inline(),
)
.map(|_| {
inner_comments(
field.deprecated(),
field.docs(),
format!("({s})"),
true,
prefix,
!ctx.cfg.jsdoc,
)
})
})
.collect::<Result<Vec<_>>>()?;
let unflattened_fields = non_flattened
.into_iter()
.map(|(key, field_ref)| {
let (field, ty) = field_ref;
let field_prefix = format!("{prefix}\t");
let mut other = String::new();
object_field_to_ts(
ctx.with(PathItem::Field(key.clone())),
key.clone(),
(field, ty),
types,
&mut other,
generics,
&field_prefix,
false,
None,
)?;
let docs = field
.docs()
.trim()
.is_empty()
.then(|| inline_reference_docs(types, (field, ty), false))
.flatten()
.unwrap_or(field.docs());
Ok(inner_comments(
field.deprecated(),
docs,
other,
false,
&field_prefix,
!ctx.cfg.jsdoc,
))
})
.collect::<Result<Vec<_>>>()?;
if !unflattened_fields.is_empty() {
let mut s = "{".to_string();
for field in unflattened_fields {
s.push('\n');
s.push_str(&field);
s.push(',');
}
s.push('\n');
s.push_str(prefix);
s.push('}');
flattened_sections.insert(0, s);
}
let mut seen = BTreeSet::new();
flattened_sections.retain(|section| seen.insert(section.clone()));
s.push_str(&flattened_sections.join(" & "));
}
}
Ok(())
}
fn enum_variant_datatype(
ctx: ExportContext,
types: &Types,
name: Cow<'static, str>,
variant: &Variant,
prefix: &str,
generics: &[(GenericReference, DataType)],
ty_override: Option<VariantTypeOverride<'_>>,
) -> Result<Option<String>> {
match &variant.fields() {
Fields::Unit => Ok(Some(sanitise_key(name, true).to_string())),
Fields::Named(obj) => {
let all_fields = obj
.fields()
.iter()
.filter_map(|(name, field)| field.ty().map(|ty| (name, (field, ty))))
.collect::<Vec<_>>();
let (flattened, non_flattened): (Vec<_>, Vec<_>) =
all_fields.iter().partition(|(_, (f, _))| f.flatten());
let field_sections = flattened
.into_iter()
.map(|(_key, (field, ty))| {
let mut s = String::new();
crate::primitives::datatype_with_inline_attr(
&mut s,
ctx.cfg,
types,
ty,
vec![],
None,
"",
generics,
field.inline(),
)
.map(|_| {
inner_comments(
field.deprecated(),
field.docs(),
format!("({s})"),
true,
prefix,
!ctx.cfg.jsdoc,
)
})
})
.collect::<Result<Vec<_>>>()?;
let mut regular_fields = vec![];
regular_fields.extend(
non_flattened
.into_iter()
.map(|(name, field_ref)| {
let (field, ty) = field_ref;
let mut other = String::new();
object_field_to_ts(
ctx.with(PathItem::Field(name.clone())),
name.clone(),
(field, ty),
types,
&mut other,
generics,
"",
false,
ty_override
.as_ref()
.filter(|override_ty| override_ty.key == name.as_ref())
.map(|override_ty| override_ty.ty),
)?;
let docs = field
.docs()
.trim()
.is_empty()
.then(|| inline_reference_docs(types, (field, ty), false))
.flatten()
.unwrap_or(field.docs());
Ok(inner_comments(
field.deprecated(),
docs,
other,
true,
prefix,
!ctx.cfg.jsdoc,
))
})
.collect::<Result<Vec<_>>>()?,
);
Ok(Some(match (&field_sections[..], ®ular_fields[..]) {
([], []) => format!("Record<{STRING}, {NEVER}>").to_string(),
([], fields) => format!("{{ {} }}", fields.join("; ")),
(_, []) => field_sections.join(" & "),
(_, _) => {
let mut sections = vec![format!("{{ {} }}", regular_fields.join("; "))];
sections.extend(field_sections);
sections.join(" & ")
}
}))
}
Fields::Unnamed(obj) => {
let fields = obj
.fields()
.iter()
.filter_map(|field| field.ty().map(|ty| (field, ty)))
.map(|(field, ty)| {
let mut s = String::new();
crate::primitives::datatype_with_inline_attr(
&mut s,
ctx.cfg,
types,
ty,
vec![],
None,
"",
generics,
field.inline(),
)
.map(|_| s)
})
.collect::<Result<Vec<_>>>()?;
Ok(match &fields[..] {
[] => {
if obj.fields().is_empty() {
Some("[]".to_string())
} else {
None
}
}
[field] if obj.fields().len() == 1 => Some(field.to_string()),
fields => Some(format!("[{}]", fields.join(", "))),
})
}
}
}
struct EnumVariantOutput {
value: String,
strict_keys: Option<BTreeSet<String>>,
}
#[derive(Debug, Clone)]
struct DiscriminatorAnalysis {
key: String,
known_literals: Vec<String>,
fallback_variant_idx: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
struct VariantTypeOverride<'a> {
key: &'a str,
ty: &'a str,
}
#[derive(Debug, Clone)]
enum DiscriminatorValue {
StringLiteral(String),
String,
}
fn analyze_discriminator(
variants: &[&(Cow<'static, str>, Variant)],
) -> Option<DiscriminatorAnalysis> {
let mut key = None::<String>;
let mut known_literals = BTreeSet::new();
let mut fallback_variant_idx = None;
for (idx, (_, variant)) in variants.iter().enumerate() {
let (variant_key, value) = variant_discriminator(variant)?;
if let Some(expected) = &key {
if expected != &variant_key {
return None;
}
} else {
key = Some(variant_key.clone());
}
match value {
DiscriminatorValue::StringLiteral(value) => {
known_literals.insert(value);
}
DiscriminatorValue::String => {
if fallback_variant_idx.replace(idx).is_some() {
return None;
}
}
}
}
if known_literals.is_empty() {
return None;
}
Some(DiscriminatorAnalysis {
key: key.expect("at least one variant when called"),
known_literals: known_literals.into_iter().collect(),
fallback_variant_idx,
})
}
fn variant_discriminator(variant: &Variant) -> Option<(String, DiscriminatorValue)> {
let Fields::Named(named) = variant.fields() else {
return None;
};
let (name, field) = named
.fields()
.iter()
.find(|(_, field)| !field.flatten() && !field.optional())?;
let ty = field.ty()?;
if matches!(ty, DataType::Primitive(specta::datatype::Primitive::str)) {
return Some((name.to_string(), DiscriminatorValue::String));
}
string_literal_datatype_value(ty)
.map(|value| (name.to_string(), DiscriminatorValue::StringLiteral(value)))
}
fn string_literal_datatype_value(ty: &DataType) -> Option<String> {
let DataType::Enum(enm) = ty else {
return None;
};
let mut variants = enm.variants().iter();
let (name, variant) = variants.next()?;
if variants.next().is_some() {
return None;
}
if !matches!(variant.fields(), Fields::Unit) {
return None;
}
Some(name.to_string())
}
fn exclude_known_literals_type(literals: &[String]) -> Option<String> {
if literals.is_empty() {
return None;
}
let known = literals
.iter()
.map(|value| format!("\"{}\"", escape_typescript_string_literal(value.as_str())))
.collect::<Vec<_>>()
.join(" | ");
Some(format!("Exclude<string, {known}>"))
}
fn untagged_strict_keys(variant: &Variant) -> Option<BTreeSet<String>> {
match variant.fields() {
Fields::Named(obj) => {
let all_fields = obj
.fields()
.iter()
.filter_map(|(name, field)| field.ty().map(|ty| (name, (field, ty))))
.collect::<Vec<_>>();
if all_fields.iter().any(|(_, (field, _))| field.flatten()) {
return None;
}
Some(
all_fields
.into_iter()
.map(|(name, _)| sanitise_key(name.clone(), false).to_string())
.collect(),
)
}
_ => None,
}
}
fn strictify_enum_variants(variants: &mut [EnumVariantOutput]) {
let strict_key_universe = variants
.iter()
.filter_map(|variant| variant.strict_keys.as_ref())
.flat_map(|keys| keys.iter().cloned())
.collect::<BTreeSet<_>>();
if strict_key_universe.len() < 2 {
return;
}
for variant in variants {
let Some(keys) = variant.strict_keys.as_ref() else {
continue;
};
let missing_keys = strict_key_universe
.iter()
.filter(|key| !keys.contains(*key))
.map(|key| format!("{key}?: {NEVER}"))
.collect::<Vec<_>>();
if missing_keys.is_empty() {
continue;
}
variant.value = format!("({}) & {{ {} }}", variant.value, missing_keys.join("; "));
}
}
pub(crate) fn enum_datatype(
ctx: ExportContext,
e: &Enum,
types: &Types,
s: &mut String,
prefix: &str,
generics: &[(GenericReference, DataType)],
) -> Result<()> {
if e.variants().is_empty() {
return Ok(write!(s, "{NEVER}")?);
}
let filtered_variants = e
.variants()
.iter()
.filter(|(_, variant)| !variant.skip())
.collect::<Vec<_>>();
let discriminator = analyze_discriminator(&filtered_variants);
let fallback_override = discriminator.as_ref().and_then(|discriminator| {
discriminator.fallback_variant_idx.and_then(|idx| {
exclude_known_literals_type(&discriminator.known_literals)
.map(|ty| (idx, discriminator.key.as_str(), ty))
})
});
let mut rendered_variants = Vec::with_capacity(filtered_variants.len());
for (idx, (variant_name, variant)) in filtered_variants.iter().enumerate() {
let variant_override = fallback_override
.as_ref()
.and_then(|(fallback_idx, key, ty)| {
if *fallback_idx == idx {
Some(VariantTypeOverride {
key,
ty: ty.as_str(),
})
} else {
None
}
});
let ts_values = enum_variant_datatype(
ctx.with(PathItem::Variant(variant_name.clone())),
types,
variant_name.clone(),
variant,
prefix,
generics,
variant_override,
)?;
rendered_variants.push(EnumVariantOutput {
value: ts_values.unwrap_or_else(|| NEVER.to_string()),
strict_keys: untagged_strict_keys(variant),
});
}
if discriminator.is_none() {
strictify_enum_variants(&mut rendered_variants);
}
let mut variants = filtered_variants
.into_iter()
.zip(rendered_variants)
.map(|((_, variant), rendered)| {
inner_comments(
variant.deprecated(),
variant.docs(),
rendered.value,
true,
prefix,
!ctx.cfg.jsdoc,
)
})
.collect::<Vec<_>>();
let mut seen = BTreeSet::new();
variants.retain(|variant| seen.insert(variant.clone()));
if variants.is_empty() {
s.push_str(NEVER);
} else {
s.push_str(&variants.join(" | "));
}
Ok(())
}
fn object_field_to_ts(
ctx: ExportContext,
key: Cow<'static, str>,
field_ref: (&Field, &DataType),
types: &Types,
s: &mut String,
generics: &[(GenericReference, DataType)],
prefix: &str,
force_inline: bool,
ty_override: Option<&str>,
) -> Result<()> {
let (field, ty) = field_ref;
let field_name_safe = sanitise_key(key, false);
let (key, ty) = match field.optional() {
true => (format!("{field_name_safe}?").into(), ty),
false => (field_name_safe, ty),
};
let value = match ty_override {
Some(ty_override) => ty_override.to_string(),
None => {
let mut value = String::new();
crate::primitives::datatype_with_inline_attr(
&mut value,
ctx.cfg,
types,
ty,
vec![],
None,
prefix,
generics,
force_inline || field.inline(),
)?;
value
}
};
Ok(write!(s, "{prefix}{key}: {value}",)?)
}
fn inline_reference_docs<'a>(
types: &'a Types,
(field, ty): (&Field, &'a DataType),
force_inline: bool,
) -> Option<&'a str> {
let DataType::Reference(Reference::Named(r)) = ty else {
return None;
};
if !(force_inline || field.inline() || r.inline()) {
return None;
}
r.get(types)
.filter(|ndt| !ndt.docs().trim().is_empty())
.map(|ndt| ndt.docs().as_ref())
}
fn sanitise_key<'a>(field_name: Cow<'static, str>, force_string: bool) -> Cow<'a, str> {
let valid = is_identifier(&field_name);
if force_string || !valid {
format!(r#""{}""#, escape_typescript_string_literal(&field_name)).into()
} else {
field_name
}
}
pub(crate) fn is_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_' || first == '$')
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
}
pub(crate) fn escape_typescript_string_literal(value: &str) -> Cow<'_, str> {
if !value.chars().any(|ch| {
ch == '"' || ch == '\\' || ch == '\u{2028}' || ch == '\u{2029}' || ch.is_control()
}) {
return Cow::Borrowed(value);
}
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => escaped.push_str(r#"\""#),
'\\' => escaped.push_str(r#"\\"#),
'\n' => escaped.push_str(r#"\n"#),
'\r' => escaped.push_str(r#"\r"#),
'\t' => escaped.push_str(r#"\t"#),
'\u{2028}' => escaped.push_str(r#"\u2028"#),
'\u{2029}' => escaped.push_str(r#"\u2029"#),
ch if ch.is_control() => {
write!(escaped, r#"\u{:04X}"#, ch as u32).expect("infallible");
}
_ => escaped.push(ch),
}
}
Cow::Owned(escaped)
}
pub(crate) fn sanitise_type_name(ctx: ExportContext, ident: &str) -> Output {
if let Some(name) = RESERVED_TYPE_NAMES.iter().find(|v| **v == ident) {
return Err(Error::forbidden_name_legacy(ctx.export_path(), name));
}
if let Some(first_char) = ident.chars().next()
&& !first_char.is_alphabetic()
&& first_char != '_'
{
return Err(Error::invalid_name_legacy(
ctx.export_path(),
ident.to_string(),
));
}
if ident
.find(|c: char| !c.is_alphanumeric() && c != '_')
.is_some()
{
return Err(Error::invalid_name_legacy(
ctx.export_path(),
ident.to_string(),
));
}
Ok(ident.to_string())
}
const STRING: &str = "string";
const NULL: &str = "null";
const NEVER: &str = "never";
pub(crate) fn js_doc(
s: &mut String,
docs: &str,
deprecated: Option<&Deprecated>,
single_line_comment: bool,
) {
if docs.is_empty() && deprecated.is_none() {
return;
}
if single_line_comment && deprecated.is_none() {
let mut lines = docs.lines();
if let (Some(line), None) = (lines.next(), lines.next()) {
s.push_str("//");
s.push_str(&escape_jsdoc_text(line));
s.push('\n');
return;
}
}
s.push_str("/**\n");
if !docs.is_empty() {
for line in docs.lines() {
s.push_str(" * ");
s.push_str(&escape_jsdoc_text(line));
s.push('\n');
}
}
if let Some(typ) = deprecated {
s.push_str(" * @deprecated");
if let Some(details) = deprecated_details(typ) {
s.push(' ');
s.push_str(&details);
}
s.push('\n');
}
s.push_str(" */\n");
}
pub(crate) fn escape_jsdoc_text(text: &str) -> Cow<'_, str> {
if text.contains("*/") {
Cow::Owned(text.replace("*/", "*\\/"))
} else {
Cow::Borrowed(text)
}
}
pub(crate) fn deprecated_details(typ: &Deprecated) -> Option<String> {
let note = typ.note().map(|v| v.trim()).filter(|v| !v.is_empty());
let since = typ.since().map(|v| v.trim()).filter(|v| !v.is_empty());
match (note, since) {
(Some(note), Some(since)) => Some(format!("{note} since {since}")),
(Some(note), None) => Some(note.to_string()),
(None, Some(since)) => Some(format!("since {since}")),
(None, None) => None,
}
}