diff --git a/json_typegen_cli/src/main.rs b/json_typegen_cli/src/main.rs index c6b2664..c4d777f 100644 --- a/json_typegen_cli/src/main.rs +++ b/json_typegen_cli/src/main.rs @@ -1,6 +1,6 @@ use clap::{App, Arg}; use json_typegen_shared::internal_util::display_error_with_causes; -use json_typegen_shared::{Options, OutputMode, codegen, codegen_from_macro, parse}; +use json_typegen_shared::{codegen, codegen_from_macro, parse, Options, OutputMode}; use std::fs::OpenOptions; use std::io::{self, Read, Write}; @@ -51,6 +51,7 @@ fn main_with_result() -> Result<(), Box> { "kotlin", "kotlin/jackson", "kotlin/kotlinx", + "scala", "python", "json_schema", "shape", diff --git a/json_typegen_shared/Cargo.toml b/json_typegen_shared/Cargo.toml index e412191..04aa6c7 100644 --- a/json_typegen_shared/Cargo.toml +++ b/json_typegen_shared/Cargo.toml @@ -27,7 +27,7 @@ thiserror = "1.0" linked-hash-map = "0.5.4" syn = { version = "0.11", features = ["full", "parsing"], optional = true } synom = { version = "0.11.3", optional = true } -indicatif = { version = "0.16.2", optional = true } +indicatif = { version = "0.18.0", optional = true } sqlparser = "0.36.1" [dev-dependencies] diff --git a/json_typegen_shared/src/generation.rs b/json_typegen_shared/src/generation.rs index d660b3b..1762f9a 100644 --- a/json_typegen_shared/src/generation.rs +++ b/json_typegen_shared/src/generation.rs @@ -2,6 +2,7 @@ pub mod json_schema; pub mod kotlin; pub mod python; pub mod rust; +pub mod scala; pub mod shape; pub mod typescript; pub mod typescript_type_alias; diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs new file mode 100644 index 0000000..d1ef4c7 --- /dev/null +++ b/json_typegen_shared/src/generation/scala.rs @@ -0,0 +1,317 @@ +use linked_hash_map::LinkedHashMap; +use std::collections::HashSet; + +use crate::options::Options; +use crate::shape::{self, Shape}; +use crate::to_singular::to_singular; +use crate::util::type_case; +use crate::ImportStyle; + +struct Ctxt { + options: Options, + imports: HashSet, + type_names: HashSet, + created_case_classes: Vec<(Shape, TypeName)>, +} + +type Ident = String; + +#[derive(Clone)] +struct TypeName { + pub raw: String, + pub safe: Ident, +} + +type Code = String; + +pub fn scala_types(name: &str, shape: &Shape, options: Options) -> Code { + let mut ctxt = Ctxt { + options, + imports: HashSet::new(), + type_names: HashSet::new(), + created_case_classes: Vec::new(), + }; + let mut prelude = String::new(); + if ctxt.options.deny_unknown_fields { + let config = import(&mut ctxt, "io.circe.generic.extras.Configuration"); + prelude += &format!("implicit val config: {config} = {config}.default",); + prelude += ".withStrictDecoding"; + prelude += "\n\n"; + } + + let (_, type_code) = type_from_shape(&mut ctxt, name, shape); + let body_code = type_code.unwrap_or_default(); + + let mut imports = ctxt.imports.drain().collect::>(); + imports.sort(); + let import_code = imports + .iter() + .fold(String::new(), |c, i| format!("{c}import {i}\n")); + + if import_code.is_empty() { + prelude + &body_code + } else { + format!("{import_code}\n\n{prelude}{body_code}") + } +} + +fn type_from_shape(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { + use crate::shape::Shape::*; + match shape { + Null => ("Option[Json]".into(), None), + Any | Bottom => ("Json".into(), None), + Bool => ("Boolean".into(), None), + StringT => ("String".into(), None), + Integer => ("Long".into(), None), + Floating => ("Double".into(), None), + Tuple(shapes, _n) => { + let folded = shape::fold_shapes(shapes.clone()); + if folded == Any && shapes.iter().any(|s| s != &Any) { + generate_tuple_type(ctxt, path, shapes) + } else { + generate_seq_type(ctxt, path, &folded) + } + } + Optional(inner) => generate_option_type(ctxt, path, inner), + Nullable(inner) => generate_option_type(ctxt, path, inner), + VecT { elem_type } => generate_seq_type(ctxt, path, elem_type), + MapT { val_type } => generate_map_type(ctxt, path, val_type), + Struct { fields } => generate_case_class_type(ctxt, path, fields, shape), + Opaque(name) => (name.clone(), None), + } +} + +fn generate_tuple_type(ctxt: &mut Ctxt, path: &str, shapes: &[Shape]) -> (Ident, Option) { + let mut types = Vec::new(); + let mut defs = Vec::new(); + + for shape in shapes { + let (typ, def) = type_from_shape(ctxt, path, shape); + types.push(typ); + if let Some(code) = def { + defs.push(code) + } + } + + (format!("({})", types.join(", ")), Some(defs.join("\n\n"))) +} + +fn generate_option_type(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { + let singular = to_singular(path); + let (inner, defs) = type_from_shape(ctxt, &singular, shape); + (format!("Option[{}]", inner), defs) +} + +fn generate_seq_type(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { + let singular = to_singular(path); + let (inner, defs) = type_from_shape(ctxt, &singular, shape); + (format!("Seq[{}]", inner), defs) +} + +fn generate_map_type(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { + let singular = to_singular(path); + let (inner, defs) = type_from_shape(ctxt, &singular, shape); + (format!("Map[String, {}]", inner), defs) +} + +fn generate_case_class_type( + ctxt: &mut Ctxt, + path: &str, + field_shapes: &LinkedHashMap, + containing_shape: &Shape, +) -> (Ident, Option) { + let existing = ctxt.created_case_classes.iter().find_map(|(s, i)| { + if s.is_acceptable_substitution_for(containing_shape) { + Some(i.safe.clone()) + } else { + None + } + }); + if let Some(ident) = existing { + (ident, None) + } else { + let class_name = type_name(path, &ctxt.type_names); + ctxt.type_names.insert(class_name.raw.clone()); + ctxt.created_case_classes + .push((containing_shape.clone(), class_name.clone())); + let mut defs: Vec = Vec::new(); + let mut fields: Vec = Vec::new(); + for (name, shape) in field_shapes.iter() { + let field_name = field_name(name); + let (field_type, child_defs) = type_from_shape(ctxt, name, shape); + if let Some(code) = child_defs { + defs.push(code); + } + let field = format!("{field_name}: {field_type}"); + fields.push(field) + } + let case_class = if !fields.is_empty() { + format!( + "case class {}(\n {},\n)", + class_name.safe, + fields.join(",\n ") + ) + } else { + format!("case class {}()", class_name.safe) + }; + let mut code = case_class; + code += "\n\n"; + code += &generate_codec(ctxt, &class_name); + if !defs.is_empty() { + code += "\n\n"; + code += &defs.join("\n\n"); + } + (class_name.safe, Some(code)) + } +} + +fn import(ctxt: &mut Ctxt, qualified: &str) -> Code { + match qualified.rsplit(".").next() { + None => qualified.into(), + Some(value) => match ctxt.options.import_style { + ImportStyle::AddImports => { + ctxt.imports.insert(qualified.into()); + value.into() + } + ImportStyle::AssumeExisting => value.into(), + ImportStyle::QualifiedPaths => qualified.into(), + }, + } +} + +fn generate_codec(ctxt: &mut Ctxt, name: &TypeName) -> Code { + let is_configured = ctxt + .imports + .contains("io.circe.generic.extras.Configuration"); + let derive_codec = if is_configured { + import(ctxt, "io.circe.generic.semiauto.deriveConfiguredCodec") + } else { + import(ctxt, "io.circe.generic.semiauto.deriveCodec") + }; + let codec_type = import(ctxt, "io.circe.Codec"); + let codec_name = sanitize_name(&format!("codec{}", name.raw)); + format!( + "implicit lazy val {}: {}[{}] = {}[{}]", + codec_name, codec_type, name.safe, derive_codec, name.safe, + ) +} + +fn field_name(name: &str) -> Ident { + if is_invalid_name(name) { + format!("`{}`", name) + } else { + name.to_owned() + } +} + +fn type_name(name: &str, used_names: &HashSet) -> TypeName { + let mut base_name = type_case(name.trim()); + if base_name.is_empty() { + base_name = "GeneratedType".into(); + } + let mut raw = base_name.clone(); + let mut n = 2; + // will fail if name is sanitized + while used_names.contains(&raw) { + raw = format!("{}{}", base_name, n); + n += 1; + } + let safe = sanitize_name(&raw); + TypeName { raw, safe } +} + +fn sanitize_name(name: &str) -> Ident { + if is_invalid_name(name) { + format!("`{}`", name) + } else { + name.to_owned() + } +} + +#[rustfmt::skip] +const RESERVED_WORDS: &[&str] = &[ + "package", "import", + "object", "class", "trait", + "sealed", "abstract", "case", "extends", "with", + "private", "protected", "override", "implicit", + "def", "val", "var", "lazy", "type", + "while", "if", "else", "for", "yield", "try", "catch", "return", "throw", + "new", "null", "this", "true", "false" +]; + +#[rustfmt::skip] +const RESERVED_CHARS: &[char] = &[ + '(', ')', '{', '}', '[', ']', // parentheses + '\'', '"', '.', ';', ',', // delimiter + ' ', '=', '@', '#', // other +]; + +// Scala is very flexible with it's naming and this is by no means complete +// For more see: https://scala-lang.org/files/archive/spec/2.13/01-lexical-syntax.html +fn is_invalid_name(name: &str) -> bool { + if let Some(c) = name.chars().next() { + if c.is_ascii_digit() || (c == '_' && name.len() == 1) { + return true; + } + } + if name.chars().any(|c| RESERVED_CHARS.contains(&c)) { + return true; + } + RESERVED_WORDS.contains(&name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_invalid_name_syntax() { + assert!(!is_invalid_name("foo")); + assert!(!is_invalid_name("Foo")); + assert!(!is_invalid_name("fooBar")); + assert!(!is_invalid_name("FooBar")); + assert!(!is_invalid_name("foo_bar")); + assert!(!is_invalid_name("Foo_Bar")); + assert!(!is_invalid_name("FOO_BAR")); + assert!(!is_invalid_name("foo2")); + assert!(!is_invalid_name("foo_!")); + + assert!(is_invalid_name("foo bar")); + assert!(is_invalid_name("[foo]")); + assert!(is_invalid_name("#foo")); + assert!(is_invalid_name("foo#")); + assert!(is_invalid_name("@foo")); + assert!(is_invalid_name("foo@")); + } + + #[test] + fn test_is_invalid_name_reserved() { + assert!(is_invalid_name("private")); + assert!(is_invalid_name("for")); + assert!(is_invalid_name("case")); + assert!(is_invalid_name("type")); + + assert!(!is_invalid_name("_type")); + assert!(!is_invalid_name("__type")); + } + + #[test] + fn test_field_name() { + assert!(field_name("foo") == "foo"); + assert!(field_name("type") == "`type`"); + assert!(field_name("foo bar") == "`foo bar`"); + } + + #[test] + fn test_type_name() { + let mut used = HashSet::new(); + assert!(type_name("foo", &used).safe == "Foo"); + assert!(type_name("type", &used).safe == "Type"); + assert!(type_name("foo bar", &used).safe == "FooBar"); + used.insert("FooBar".to_owned()); + assert!(type_name("foo_bar", &used).safe == "FooBar2"); + assert!(type_name("123", &used).safe == "`123`"); + used.insert("`123`".to_owned()); + assert!(type_name("123", &used).safe == "`123`"); + } +} diff --git a/json_typegen_shared/src/hints.rs b/json_typegen_shared/src/hints.rs index 745cc9b..9011566 100644 --- a/json_typegen_shared/src/hints.rs +++ b/json_typegen_shared/src/hints.rs @@ -71,27 +71,27 @@ impl<'a> Hints<'a> { } /// ([/a/b, /a/c, /d/e], "a") -> [/b, /c] - pub fn step_field(&self, name: &str) -> Hints { + pub fn step_field(&self, name: &str) -> Hints<'_> { self.step(|first| first == "-" || first == name) } /// [/1/b, /a/c, /-/e] -> [/b, /c, /e] - pub fn step_any(&self) -> Hints { + pub fn step_any(&self) -> Hints<'_> { self.step(|_first| true) } /// [/1/b, /a/c, /-/e] -> [/b, /e] - pub fn step_array(&self) -> Hints { + pub fn step_array(&self) -> Hints<'_> { self.step(is_index) } /// ([/2/b, /a/c, /-/e, /3/d], 3) -> [/e, /d] - pub fn step_index(&self, index: usize) -> Hints { + pub fn step_index(&self, index: usize) -> Hints<'_> { let i_str = &index.to_string(); self.step(|first| first == "-" || first == i_str) } - fn step bool>(&self, pred: F) -> Hints { + fn step bool>(&self, pred: F) -> Hints<'_> { let mut filtered = Vec::new(); let mut applicable = Vec::new(); diff --git a/json_typegen_shared/src/lib.rs b/json_typegen_shared/src/lib.rs index 87ee91b..ffd6c65 100644 --- a/json_typegen_shared/src/lib.rs +++ b/json_typegen_shared/src/lib.rs @@ -135,6 +135,7 @@ pub fn codegen_from_shape(name: &str, shape: &Shape, options: Options) -> Result OutputMode::TypescriptTypeAlias => { generation::typescript_type_alias::typescript_type_alias(name, shape, options) } + OutputMode::Scala => generation::scala::scala_types(name, shape, options), OutputMode::PythonPydantic => generation::python::python_types(name, shape, options), }; @@ -162,7 +163,7 @@ fn handle_pub_in_name<'a>(name: &'a str, options: &mut Options) -> &'a str { name } -fn infer_source_type(s: &str) -> SampleSource { +fn infer_source_type(s: &str) -> SampleSource<'_> { let s = s.trim(); if s.starts_with('{') || s.starts_with('[') { return SampleSource::Text(s); diff --git a/json_typegen_shared/src/options.rs b/json_typegen_shared/src/options.rs index ea1adcf..38fa8c9 100644 --- a/json_typegen_shared/src/options.rs +++ b/json_typegen_shared/src/options.rs @@ -84,6 +84,7 @@ pub enum OutputMode { TypescriptTypeAlias, KotlinJackson, KotlinKotlinx, + Scala, PythonPydantic, JsonSchema, ZodSchema, @@ -99,6 +100,7 @@ impl OutputMode { "kotlin" => Some(OutputMode::KotlinJackson), "kotlin/jackson" => Some(OutputMode::KotlinJackson), "kotlin/kotlinx" => Some(OutputMode::KotlinKotlinx), + "scala" => Some(OutputMode::Scala), "python" => Some(OutputMode::PythonPydantic), "json_schema" => Some(OutputMode::JsonSchema), "zod" => Some(OutputMode::ZodSchema), diff --git a/json_typegen_shared/src/progress.rs b/json_typegen_shared/src/progress.rs index 0b4d2eb..d52ad1c 100644 --- a/json_typegen_shared/src/progress.rs +++ b/json_typegen_shared/src/progress.rs @@ -14,9 +14,9 @@ impl FileWithProgress { let len = file.metadata()?.len(); Ok(FileWithProgress { file, - progress: ProgressBar::new(len).with_style(ProgressStyle::default_bar().template( + progress: ProgressBar::new(len).with_style(ProgressStyle::with_template( "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} Processing file...", - )), + ).unwrap()), }) } }