From e58d840277f99364c714b49bdfe9ff76ffa9a4bf Mon Sep 17 00:00:00 2001 From: "Peter A." Date: Thu, 24 Jul 2025 01:43:16 +0200 Subject: [PATCH 01/11] indicatif -> 0.18.0 --- json_typegen_shared/Cargo.toml | 2 +- json_typegen_shared/src/progress.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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()), }) } } From 3e247fa977cf56de4046e6ae9501fb180fcb5ef3 Mon Sep 17 00:00:00 2001 From: "Peter A." Date: Thu, 24 Jul 2025 01:45:16 +0200 Subject: [PATCH 02/11] fixed warnings about self's lifetime implicitly flowing to return value, by making it explicit --- json_typegen_shared/src/hints.rs | 10 +++++----- json_typegen_shared/src/lib.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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..86dc77d 100644 --- a/json_typegen_shared/src/lib.rs +++ b/json_typegen_shared/src/lib.rs @@ -162,7 +162,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); From 8b722dd440fef383c4572d2b0aa3aecdb202f29a Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Thu, 5 Jun 2025 21:41:01 -0500 Subject: [PATCH 03/11] Add scala --- json_typegen_cli/src/main.rs | 3 +- json_typegen_shared/src/generation.rs | 1 + json_typegen_shared/src/generation/scala.rs | 248 ++++++++++++++++++++ json_typegen_shared/src/lib.rs | 1 + json_typegen_shared/src/options.rs | 2 + 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 json_typegen_shared/src/generation/scala.rs 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/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..2c6f000 --- /dev/null +++ b/json_typegen_shared/src/generation/scala.rs @@ -0,0 +1,248 @@ +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; + +struct Ctxt { + options: Options, + type_names: HashSet, + created_case_classes: Vec<(Shape, Ident)>, +} + +type Ident = String; +type Code = String; + +pub fn scala_types(name: &str, shape: &Shape, options: Options) -> Code { + let mut ctxt = Ctxt { + options, + type_names: HashSet::new(), + created_case_classes: Vec::new(), + }; + let (_ident, code) = type_from_shape(&mut ctxt, name, shape); + code.unwrap_or_default() +} + +fn type_from_shape(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { + use crate::shape::Shape::*; + match shape { + Null | Any | Bottom => ("Json".into(), None), + Bool => ("Boolean".into(), None), + StringT => ("String".into(), None), + Integer => ("Int".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.into()) + } 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.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, fields.join(",\n ")) + } else { + format!("case class {}()", class_name) + }; + let mut code = case_class; + if !defs.is_empty() { + code += "\n\n"; + code += &defs.join("\n\n"); + } + (class_name, Some(code)) + } +} + +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) -> Ident { + let mut base_name = type_case(name.trim()); + if base_name.is_empty() { + base_name = "GeneratedType".into(); + } + let mut output_name = base_name.clone(); + let mut n = 2; + // will fail if name is sanitized + while used_names.contains(&output_name) { + output_name = format!("{}{}", base_name, n); + n += 1; + } + sanitize_name(&output_name) +} + +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) == "Foo"); + assert!(type_name("type", &used) == "Type"); + assert!(type_name("foo bar", &used) == "FooBar"); + used.insert("FooBar".to_owned()); + assert!(type_name("foo_bar", &used) == "FooBar2"); + assert!(type_name("123", &used) == "`123`"); + used.insert("`123`".to_owned()); + assert!(type_name("123", &used) == "`1232`"); + } +} diff --git a/json_typegen_shared/src/lib.rs b/json_typegen_shared/src/lib.rs index 86dc77d..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), }; 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), From f083d36f0bfd602fd45652f56ed26fb68470219e Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Tue, 10 Jun 2025 14:02:19 -0500 Subject: [PATCH 04/11] fix(scala): 2 space indent --- json_typegen_shared/src/generation/scala.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 2c6f000..b4dce38 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -111,7 +111,7 @@ fn generate_case_class_type( if let Some(code) = child_defs { defs.push(code); } - let field = format!(" {field_name}: {field_type}"); + let field = format!("{field_name}: {field_type}"); fields.push(field) } let case_class = if !fields.is_empty() { From ce0996c9ba99da9bd05fefa4b410f7c73fcedd79 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Tue, 10 Jun 2025 14:02:34 -0500 Subject: [PATCH 05/11] feat(scala): trailing commas --- json_typegen_shared/src/generation/scala.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index b4dce38..0f7ca88 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -115,7 +115,7 @@ fn generate_case_class_type( fields.push(field) } let case_class = if !fields.is_empty() { - format!("case class {}(\n {}\n)", class_name, fields.join(",\n ")) + format!("case class {}(\n {},\n)", class_name, fields.join(",\n ")) } else { format!("case class {}()", class_name) }; From 4d8bb11d3fe084662e97f0cb5746d1baa83dcdf3 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Tue, 10 Jun 2025 14:03:12 -0500 Subject: [PATCH 06/11] feat(scala): imports --- json_typegen_shared/src/generation/scala.rs | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 0f7ca88..013dee5 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -5,9 +5,11 @@ 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, Ident)>, } @@ -18,11 +20,21 @@ 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 (_ident, code) = type_from_shape(&mut ctxt, name, shape); - 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() { + code.unwrap_or_default() + } else { + format!("{}\n\n{}", import_code, code.unwrap_or_default()) + } } fn type_from_shape(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { @@ -128,6 +140,20 @@ fn generate_case_class_type( } } +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 field_name(name: &str) -> Ident { if is_invalid_name(name) { format!("`{}`", name) From c61a138c8b1dbe4f60471fd14bbed43d742c4437 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Tue, 10 Jun 2025 14:03:28 -0500 Subject: [PATCH 07/11] feat(scala): generate codecs --- json_typegen_shared/src/generation/scala.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 013dee5..08bb40e 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -132,6 +132,8 @@ fn generate_case_class_type( format!("case class {}()", class_name) }; 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"); @@ -154,6 +156,15 @@ fn import(ctxt: &mut Ctxt, qualified: &str) -> Code { } } +fn generate_codec(ctxt: &mut Ctxt, name: &str) -> Code { + let codec_type = import(ctxt, "io.circe.Codec"); + let derive_codec = import(ctxt, "io.circe.generic.semiauto.deriveCodec"); + format!( + "implicit lazy val codec{}: {}[{}] = {}[{}]", + name, codec_type, name, derive_codec, name, + ) +} + fn field_name(name: &str) -> Ident { if is_invalid_name(name) { format!("`{}`", name) From 802d8880fa984f8ea66ceebb2b638cb01b8b4dc7 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Thu, 12 Jun 2025 17:57:23 -0500 Subject: [PATCH 08/11] fix(scala): Handle unsafe class names in codecs --- json_typegen_shared/src/generation/scala.rs | 55 +++++++++++++-------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 08bb40e..e1835d4 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -11,10 +11,17 @@ struct Ctxt { options: Options, imports: HashSet, type_names: HashSet, - created_case_classes: Vec<(Shape, Ident)>, + 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 { @@ -24,7 +31,7 @@ pub fn scala_types(name: &str, shape: &Shape, options: Options) -> Code { type_names: HashSet::new(), created_case_classes: Vec::new(), }; - let (_ident, code) = type_from_shape(&mut ctxt, name, shape); + let (_, code) = type_from_shape(&mut ctxt, name, shape); let mut imports = ctxt.imports.drain().collect::>(); imports.sort(); let import_code = imports @@ -103,7 +110,7 @@ fn generate_case_class_type( ) -> (Ident, Option) { let existing = ctxt.created_case_classes.iter().find_map(|(s, i)| { if s.is_acceptable_substitution_for(containing_shape) { - Some(i.into()) + Some(i.safe.clone()) } else { None } @@ -112,7 +119,7 @@ fn generate_case_class_type( (ident, None) } else { let class_name = type_name(path, &ctxt.type_names); - ctxt.type_names.insert(class_name.clone()); + 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(); @@ -127,9 +134,13 @@ fn generate_case_class_type( fields.push(field) } let case_class = if !fields.is_empty() { - format!("case class {}(\n {},\n)", class_name, fields.join(",\n ")) + format!( + "case class {}(\n {},\n)", + class_name.safe, + fields.join(",\n ") + ) } else { - format!("case class {}()", class_name) + format!("case class {}()", class_name.safe) }; let mut code = case_class; code += "\n\n"; @@ -138,7 +149,7 @@ fn generate_case_class_type( code += "\n\n"; code += &defs.join("\n\n"); } - (class_name, Some(code)) + (class_name.safe, Some(code)) } } @@ -156,12 +167,13 @@ fn import(ctxt: &mut Ctxt, qualified: &str) -> Code { } } -fn generate_codec(ctxt: &mut Ctxt, name: &str) -> Code { +fn generate_codec(ctxt: &mut Ctxt, name: &TypeName) -> Code { let codec_type = import(ctxt, "io.circe.Codec"); let derive_codec = import(ctxt, "io.circe.generic.semiauto.deriveCodec"); + let codec_name = sanitize_name(&format!("codec{}", name.raw)); format!( - "implicit lazy val codec{}: {}[{}] = {}[{}]", - name, codec_type, name, derive_codec, name, + "implicit lazy val {}: {}[{}] = {}[{}]", + codec_name, codec_type, name.safe, derive_codec, name.safe, ) } @@ -173,19 +185,20 @@ fn field_name(name: &str) -> Ident { } } -fn type_name(name: &str, used_names: &HashSet) -> Ident { +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 output_name = base_name.clone(); + let mut raw = base_name.clone(); let mut n = 2; // will fail if name is sanitized - while used_names.contains(&output_name) { - output_name = format!("{}{}", base_name, n); + while used_names.contains(&raw) { + raw = format!("{}{}", base_name, n); n += 1; } - sanitize_name(&output_name) + let safe = sanitize_name(&raw); + TypeName { raw, safe } } fn sanitize_name(name: &str) -> Ident { @@ -273,13 +286,13 @@ mod tests { #[test] fn test_type_name() { let mut used = HashSet::new(); - assert!(type_name("foo", &used) == "Foo"); - assert!(type_name("type", &used) == "Type"); - assert!(type_name("foo bar", &used) == "FooBar"); + 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) == "FooBar2"); - assert!(type_name("123", &used) == "`123`"); + 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) == "`1232`"); + assert!(type_name("123", &used).safe == "`123`"); } } From 2ed6ebe067b2e4a7443864109ac778a850d637da Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Thu, 12 Jun 2025 21:01:09 -0500 Subject: [PATCH 09/11] feat(scala): support deny_unknown_fields --- json_typegen_shared/src/generation/scala.rs | 26 +++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index e1835d4..6165ed2 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -31,16 +31,27 @@ pub fn scala_types(name: &str, shape: &Shape, options: Options) -> Code { type_names: HashSet::new(), created_case_classes: Vec::new(), }; - let (_, code) = type_from_shape(&mut ctxt, name, shape); + 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() { - code.unwrap_or_default() + prelude + &body_code } else { - format!("{}\n\n{}", import_code, code.unwrap_or_default()) + format!("{import_code}\n\n{prelude}{body_code}") } } @@ -168,8 +179,15 @@ fn import(ctxt: &mut Ctxt, qualified: &str) -> Code { } 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 derive_codec = import(ctxt, "io.circe.generic.semiauto.deriveCodec"); let codec_name = sanitize_name(&format!("codec{}", name.raw)); format!( "implicit lazy val {}: {}[{}] = {}[{}]", From 03c060cfedd68dc2afecf95ea444062498654030 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Mon, 28 Jul 2025 12:20:52 -0500 Subject: [PATCH 10/11] fix(scala): replace Int with Long --- json_typegen_shared/src/generation/scala.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 6165ed2..39bf936 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -61,7 +61,7 @@ fn type_from_shape(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option Null | Any | Bottom => ("Json".into(), None), Bool => ("Boolean".into(), None), StringT => ("String".into(), None), - Integer => ("Int".into(), None), + Integer => ("Long".into(), None), Floating => ("Double".into(), None), Tuple(shapes, _n) => { let folded = shape::fold_shapes(shapes.clone()); From 7d125f82e890692a6422843640d65bbaeca4d0a2 Mon Sep 17 00:00:00 2001 From: Philip Wales Date: Fri, 23 Jan 2026 16:11:51 -0600 Subject: [PATCH 11/11] fix(scala): Null is Option[Json] --- json_typegen_shared/src/generation/scala.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/json_typegen_shared/src/generation/scala.rs b/json_typegen_shared/src/generation/scala.rs index 39bf936..d1ef4c7 100644 --- a/json_typegen_shared/src/generation/scala.rs +++ b/json_typegen_shared/src/generation/scala.rs @@ -58,7 +58,8 @@ pub fn scala_types(name: &str, shape: &Shape, options: Options) -> Code { fn type_from_shape(ctxt: &mut Ctxt, path: &str, shape: &Shape) -> (Ident, Option) { use crate::shape::Shape::*; match shape { - Null | Any | Bottom => ("Json".into(), None), + Null => ("Option[Json]".into(), None), + Any | Bottom => ("Json".into(), None), Bool => ("Boolean".into(), None), StringT => ("String".into(), None), Integer => ("Long".into(), None),