use crate::schema::{RenderResult, SchemaRenderer};
use convert_case::{Case, Casing};
use indexmap::IndexMap;
use schematic_types::*;
use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Default)]
pub enum EnumFormat {
Enum,
ValuedEnum,
#[default]
Union,
}
#[derive(Default)]
pub enum ObjectFormat {
#[default]
Interface,
Type,
}
#[derive(Default)]
pub enum PropertyFormat {
#[default]
Required,
Optional,
OptionalUndefined,
}
#[derive(Default)]
pub struct TypeScriptOptions {
pub const_enum: bool,
pub disable_references: bool,
pub enum_format: EnumFormat,
pub exclude_aliases: bool,
pub exclude_references: Vec<String>,
pub external_types: HashMap<String, Vec<String>>,
pub indent_char: String,
pub object_format: ObjectFormat,
pub property_format: PropertyFormat,
}
#[derive(Default)]
pub struct TypeScriptRenderer {
depth: usize,
options: TypeScriptOptions,
references: HashSet<String>,
}
impl TypeScriptRenderer {
pub fn new(options: TypeScriptOptions) -> Self {
Self {
depth: 0,
options,
references: HashSet::default(),
}
}
fn indent(&self) -> String {
let chars = if self.options.indent_char.is_empty() {
"\t"
} else {
&self.options.indent_char
};
if self.depth == 0 {
String::new()
} else {
chars.repeat(self.depth)
}
}
fn is_excluded(&self, name: &str) -> bool {
self.options.exclude_references.iter().any(|r| r == name)
}
fn is_external(&self, name: &str) -> bool {
for externals in self.options.external_types.values() {
if externals.iter().any(|e| e == name) {
return true;
}
}
false
}
fn is_string_union_enum(&self, enu: &EnumType) -> bool {
matches!(self.options.enum_format, EnumFormat::Union)
|| enu.variants.is_none()
|| enu.variants.as_ref().is_some_and(|v| v.len() != enu.values.len())
|| self.options.disable_references
}
fn export_type_alias(&mut self, name: &str, value: String) -> RenderResult {
Ok(format!("export type {name} = {value};"))
}
fn export_enum_type(&mut self, name: &str, enu: &EnumType, schema: &Schema) -> RenderResult {
let value = self.render_enum(enu, schema)?;
let mut tags = vec![];
let output = if self.is_string_union_enum(enu) {
self.export_type_alias(name, value)?
} else {
let out = format!("enum {name} {value}");
if self.options.const_enum {
format!("export const {out}")
} else {
format!("export {out}")
}
};
if let Some(deprecated) = &schema.deprecated {
tags.push(if deprecated.is_empty() {
"@deprecated".to_owned()
} else {
format!("@deprecated {deprecated}")
});
}
Ok(self.wrap_in_comment(schema.description.as_ref(), tags, output))
}
fn export_object_type(&mut self, name: &str, schema: &Schema, value: String) -> RenderResult {
let mut tags = vec![];
let output = if matches!(self.options.object_format, ObjectFormat::Interface)
&& value.starts_with('{')
&& value.ends_with('}')
{
format!("export interface {name} {value}")
} else {
self.export_type_alias(name, value)?
};
if let Some(deprecated) = &schema.deprecated {
tags.push(if deprecated.is_empty() {
"@deprecated".to_owned()
} else {
format!("@deprecated {deprecated}")
});
}
Ok(self.wrap_in_comment(schema.description.as_ref(), tags, output))
}
fn export_object_types(
&mut self,
name: &str,
structure: &StructType,
schema: &Schema,
) -> RenderResult<Vec<String>> {
let mut outputs = vec![];
let mut extends = vec![];
for (field_name, field) in &structure.fields {
if field.flatten
&& let Some(schema) = field.schema.get_nonnull_schema()
{
let name = format!(
"{name}{}",
field_name.from_case(Case::Snake).to_case(Case::Pascal)
);
let value = self.render_schema(schema)?;
outputs.push(self.export_object_type(&name, &field.schema, value)?);
extends.push(name);
}
}
let value = self.render_struct(structure, schema)?;
if extends.is_empty() {
outputs.push(self.export_object_type(name, schema, value)?);
}
else {
let base_name = format!("{name}Base");
outputs.push(self.export_object_type(&base_name, schema, value)?);
extends.push(base_name);
extends.reverse();
outputs.push(self.export_object_type(name, schema, extends.join(" & "))?)
}
Ok(outputs)
}
fn render_enum_as_string_union(&mut self, enu: &EnumType, schema: &Schema) -> RenderResult {
let variants_types = match &enu.variants {
Some(variants) => variants
.iter()
.filter_map(|(_, variant)| {
if variant.hidden {
None
} else {
Some(Box::new(variant.schema.clone()))
}
})
.collect::<Vec<_>>(),
_ => enu
.values
.iter()
.map(|v| Box::new(Schema::literal_value(v.clone())))
.collect::<Vec<_>>(),
};
self.render_union(
&UnionType {
variants_types,
..Default::default()
},
schema,
)
}
fn render_enum_or_union(&mut self, enu: &EnumType, schema: &Schema) -> RenderResult {
if self.is_string_union_enum(enu) {
return self.render_enum_as_string_union(enu, schema);
}
self.depth += 1;
let mut out = vec![];
let indent = self.indent();
for (name, variant) in enu.variants.as_ref().unwrap() {
if variant.hidden {
continue;
}
let field = if matches!(self.options.enum_format, EnumFormat::ValuedEnum) {
format!(
"{}{} = {},",
indent,
name,
self.render_schema(&variant.schema)?
)
} else {
format!("{indent}{name},")
};
let mut tags = vec![];
if let Some(default) = variant.schema.get_default() {
tags.push(format!("@default {}", self.lit_to_string(default)));
}
out.push(self.wrap_in_comment(variant.comment.as_ref(), tags, field));
}
self.depth -= 1;
Ok(format!("{{\n{}\n{}}}", out.join("\n"), self.indent()))
}
fn lit_to_string(&self, lit: &LiteralValue) -> String {
match lit {
LiteralValue::Bool(inner) => inner.to_string(),
LiteralValue::F32(inner) => inner.to_string(),
LiteralValue::F64(inner) => inner.to_string(),
LiteralValue::Int(inner) => inner.to_string(),
LiteralValue::UInt(inner) => inner.to_string(),
LiteralValue::String(inner) => format!("'{inner}'"),
}
}
fn wrap_in_comment(
&self,
comment: Option<&String>,
tags: Vec<String>,
value: String,
) -> String {
let indent = self.indent();
let mut lines = vec![];
if let Some(comment) = comment {
lines.extend(
comment
.trim()
.split('\n')
.map(|c| c.trim().to_owned())
.collect::<Vec<_>>(),
);
}
if !tags.is_empty() {
if !lines.is_empty() {
lines.push("".to_owned());
}
lines.extend(tags);
}
if lines.is_empty() {
return value;
}
if lines.len() == 1 {
return format!("{}/** {} */\n{}", indent, lines[0], value);
}
let mut out = vec![format!("{}/**", indent)];
for line in lines {
if line.is_empty() {
out.push(format!("{indent} *"));
} else {
out.push(format!("{} * {}", indent, line.trim()));
}
}
out.push(format!("{indent} */"));
format!("{}\n{}", out.join("\n"), value)
}
}
impl SchemaRenderer<String> for TypeScriptRenderer {
fn is_reference(&self, name: &str) -> bool {
if self.options.disable_references {
return false;
}
if self.references.contains(name) {
return true;
}
self.is_external(name)
}
fn render_array(&mut self, array: &ArrayType, _schema: &Schema) -> RenderResult {
let out = self.render_schema(&array.items_type)?;
Ok(if out.contains('|') {
format!("({out})[]")
} else {
format!("{out}[]")
})
}
fn render_boolean(&mut self, _boolean: &BooleanType, _schema: &Schema) -> RenderResult {
Ok("boolean".into())
}
fn render_enum(&mut self, enu: &EnumType, schema: &Schema) -> RenderResult {
self.render_enum_or_union(enu, schema)
}
fn render_float(&mut self, float: &FloatType, _schema: &Schema) -> RenderResult {
if let Some(values) = &float.enum_values {
return Ok(values
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(" | "));
}
Ok("number".into())
}
fn render_integer(&mut self, integer: &IntegerType, _schema: &Schema) -> RenderResult {
if let Some(values) = &integer.enum_values {
return Ok(values
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(" | "));
}
Ok("number".into())
}
fn render_literal(&mut self, literal: &LiteralType, _schema: &Schema) -> RenderResult {
Ok(self.lit_to_string(&literal.value))
}
fn render_null(&mut self, _schema: &Schema) -> RenderResult {
Ok("null".into())
}
fn render_object(&mut self, object: &ObjectType, _schema: &Schema) -> RenderResult {
Ok(format!(
"Record<{}, {}>",
self.render_schema(&object.key_type)?,
self.render_schema(&object.value_type)?
))
}
fn render_reference(&mut self, reference: &str, _schema: &Schema) -> RenderResult {
Ok(reference.into())
}
fn render_string(&mut self, string: &StringType, _schema: &Schema) -> RenderResult {
if let Some(values) = &string.enum_values {
return Ok(values
.iter()
.map(|v| format!("'{v}'"))
.collect::<Vec<_>>()
.join(" | "));
}
Ok("string".into())
}
fn render_struct(&mut self, structure: &StructType, _schema: &Schema) -> RenderResult {
self.depth += 1;
let exclude_aliases = self.options.exclude_aliases;
let mut out = vec![];
let indent = self.indent();
let mut create_row = |name: &str, field: &SchemaField| -> miette::Result<()> {
if field.hidden {
return Ok(());
}
let mut row = format!("{indent}{name}");
if field.optional
|| field.aliases.iter().any(|alias| name == alias)
|| matches!(
self.options.property_format,
PropertyFormat::Optional | PropertyFormat::OptionalUndefined
)
{
row.push_str("?: ");
} else {
row.push_str(": ");
}
row.push_str(&self.render_schema(&field.schema)?);
if matches!(
self.options.property_format,
PropertyFormat::OptionalUndefined
) {
row.push_str(" | undefined");
}
if matches!(self.options.object_format, ObjectFormat::Interface) {
row.push(';');
} else {
row.push(',');
}
let mut tags = vec![];
if let Some(default) = field.schema.get_default() {
tags.push(format!("@default {}", self.lit_to_string(default)));
}
if let Some(deprecated) = &field.deprecated {
tags.push(if deprecated.is_empty() {
"@deprecated".to_owned()
} else {
format!("@deprecated {deprecated}")
});
}
if let Some(env_var) = &field.env_var {
tags.push(format!("@env {env_var}"));
}
if let SchemaType::Enum(inner) = &field.schema.ty {
tags.push(format!(
"@type {{{}}}",
self.render_enum_as_string_union(inner, &field.schema)?
));
}
out.push(self.wrap_in_comment(field.comment.as_ref(), tags, row));
Ok(())
};
for (name, field) in &structure.fields {
if field.flatten {
continue;
}
if !exclude_aliases {
for alias in &field.aliases {
create_row(alias, field)?;
}
}
create_row(name, field)?;
}
self.depth -= 1;
Ok(format!("{{\n{}\n{}}}", out.join("\n"), self.indent()))
}
fn render_tuple(&mut self, tuple: &TupleType, _schema: &Schema) -> RenderResult {
let mut items = vec![];
for item in &tuple.items_types {
items.push(self.render_schema(item)?);
}
Ok(format!("[{}]", items.join(", ")))
}
fn render_union(&mut self, uni: &UnionType, _schema: &Schema) -> RenderResult {
let mut items = vec![];
for item in &uni.variants_types {
items.push(self.render_schema(item)?);
}
Ok(items.join(" | "))
}
fn render_unknown(&mut self, _schema: &Schema) -> RenderResult {
Ok("unknown".into())
}
fn render(&mut self, schemas: IndexMap<String, Schema>) -> RenderResult {
self.references = HashSet::from_iter(schemas.keys().cloned());
let mut outputs = vec![
"// Automatically generated by schematic. DO NOT MODIFY!".to_string(),
"/* eslint-disable */".to_string(),
];
let mut imports = vec![];
for (import, types) in BTreeMap::from_iter(&self.options.external_types) {
let mut imported_types = types.to_vec();
imported_types.sort();
imports.push(format!(
"import type {{ {} }} from '{import}';",
imported_types.join(", "),
));
}
if !imports.is_empty() {
outputs.push(imports.join("\n"));
}
for (name, schema) in &schemas {
if self.is_excluded(name) {
continue;
}
match &schema.ty {
SchemaType::Enum(inner) => {
outputs.push(self.export_enum_type(name, inner, schema)?)
}
SchemaType::Struct(inner) => {
outputs.extend(self.export_object_types(name, inner, schema)?);
}
_ => {
let out = self.render_schema_without_reference(schema)?;
outputs.push(self.export_type_alias(name, out)?);
}
};
}
Ok(outputs.join("\n\n"))
}
}