From 7fffe9217e5fb71523767e0831f4c5d2797692cc Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Wed, 3 Jan 2024 16:00:20 -0800 Subject: [PATCH] new: Add a config/file template renderer. (#86) --- CHANGELOG.md | 5 +- book/src/SUMMARY.md | 2 +- book/src/schema/generator/index.md | 1 + book/src/schema/generator/json-schema.md | 2 +- book/src/schema/generator/template.md | 211 ++++++++ book/src/schema/generator/typescript.md | 18 +- crates/config/Cargo.toml | 2 + crates/config/src/config/format.rs | 24 +- crates/config/src/config/mod.rs | 1 - crates/config/src/format.rs | 56 +++ crates/config/src/lib.rs | 2 + crates/config/src/schema/mod.rs | 4 + .../src/schema/renderers/json_schema.rs | 6 +- crates/config/src/schema/renderers/mod.rs | 3 + .../config/src/schema/renderers/template.rs | 466 ++++++++++++++++++ .../config/src/schema/renderers/typescript.rs | 34 +- crates/config/tests/generator_test.rs | 131 +++++ ...generator_test__json_schema__defaults.snap | 4 + ...nerator_test__template_json__defaults.snap | 52 ++ ...nerator_test__template_toml__defaults.snap | 49 ++ ...nerator_test__template_yaml__defaults.snap | 48 ++ ...nerator_test__typescript__const_enums.snap | 7 + .../generator_test__typescript__defaults.snap | 7 + .../generator_test__typescript__enums.snap | 7 + ...erator_test__typescript__exclude_refs.snap | 9 +- ...ator_test__typescript__external_types.snap | 9 +- .../generator_test__typescript__no_refs.snap | 13 + ...ator_test__typescript__object_aliases.snap | 7 + ...nerator_test__typescript__value_enums.snap | 7 + .../macros_test__generates_json_schema.snap | 4 + .../macros_test__generates_typescript-2.snap | 4 + .../macros_test__generates_typescript.snap | 4 + crates/macros/src/common/container.rs | 13 +- crates/macros/src/common/field.rs | 4 +- crates/macros/src/common/variant.rs | 2 +- crates/macros/src/config_enum/mod.rs | 14 +- crates/macros/src/config_enum/variant.rs | 15 +- crates/macros/src/utils.rs | 2 +- crates/types/src/enums.rs | 1 + crates/types/src/lib.rs | 43 +- crates/types/src/unions.rs | 7 + 41 files changed, 1228 insertions(+), 72 deletions(-) create mode 100644 book/src/schema/generator/template.md create mode 100644 crates/config/src/format.rs create mode 100644 crates/config/src/schema/renderers/template.rs create mode 100644 crates/config/tests/snapshots/generator_test__template_json__defaults.snap create mode 100644 crates/config/tests/snapshots/generator_test__template_toml__defaults.snap create mode 100644 crates/config/tests/snapshots/generator_test__template_yaml__defaults.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index a000121c..a9ce9088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,17 @@ `HashSet`. - Updated `EnumType.variants` to `Vec` instead of `Vec`. - Updated `ObjectType.required` and `StructType.required` to be wrapped in `Option`. - - Updated `SchemaField.deprecated` to `Option` from `bool`. + - Updated `SchemaField.deprecated` to `Option` instead of `bool`. + - Updated `SchemaField.name` to `String` instead of `Option`. #### 🚀 Updates - Added official documentation: https://moonrepo.github.io/schematic +- Added a new file template generator. - Added constructor methods for schema types. - Added `SchemaType::enumerable` method. - Added `SchemaField.env_var` field. +- Added `EnumType.default_index` and `UnionType.default_index` fields. - Updated `typescript` comment output to include `@deprecated` and `@envvar`. - Reduced the amount of code that macros generate for the `Schematic` implementation. diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 5b43a7df..0ced3857 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -33,6 +33,6 @@ - [External types](./schema/external.md) - [Code generation](./schema/generator/index.md) - [API documentation]() - - [File templates]() + - [File templates](./schema/generator/template.md) - [JSON schemas](./schema/generator/json-schema.md) - [TypeScript types](./schema/generator/typescript.md) diff --git a/book/src/schema/generator/index.md b/book/src/schema/generator/index.md index 42577404..bc0634f1 100644 --- a/book/src/schema/generator/index.md +++ b/book/src/schema/generator/index.md @@ -68,5 +68,6 @@ implementing the [`SchemaRenderer`](https://docs.rs/schematic/latest/schematic/schema/trait.SchemaRenderer.html) trait. +- [File templates](./template.md) - [JSON schemas](./json-schema.md) - [TypeScript types](./typescript.md) diff --git a/book/src/schema/generator/json-schema.md b/book/src/schema/generator/json-schema.md index e3b2da40..d1cd97fb 100644 --- a/book/src/schema/generator/json-schema.md +++ b/book/src/schema/generator/json-schema.md @@ -26,7 +26,7 @@ generator.generate(output_dir.join("schema.json"), JsonSchemaRenderer::default() Unlike other renderers, a JSON schema represents a single document, with referenced types being organized into definitions. In Schematic, the _last type to be added to `SchemaGenerator`_ will be -the document root, while all other types will become definitions. For example: +the root document, while all other types will become definitions. For example: ```rust // These are definitions diff --git a/book/src/schema/generator/template.md b/book/src/schema/generator/template.md new file mode 100644 index 00000000..6b32a926 --- /dev/null +++ b/book/src/schema/generator/template.md @@ -0,0 +1,211 @@ +# File templates (experimental) + +> Requires the `template` and desired [format](../../config/index.md#supported-source-formats) Cargo +> feature. + +With our +[`TemplateRenderer`](https://docs.rs/schematic/latest/schematic/schema/template/struct.TemplateRenderer.html), +you can generate a file template in a specific format (JSON, TOML, YAML). This template will include +all fields, default values, comments, metadata, and is useful for situations like configuration +templates and scaffolding defaults. + +To utilize, instantiate a generator, add types to render, and generate the output file. + +```rust +use schematic::Format; +use schematic::schema::{SchemaGenerator, template::*}; + +let mut generator = SchemaGenerator::default(); +generator.add::(); +generator.generate(output_dir.join("config.json"), TemplateRenderer::new_format(Format::Json))?; +``` + +## Root document + +A template represents a single document, typically for a struct. In Schematic, the _last type to be +added to `SchemaGenerator`_ will be the root document, while all other types will be ignored. For +example: + +```rust +// These are only used for type information +generator.add::(); +generator.add::(); +generator.add::(); + +// This is the root document +generator.add::(); +generator.generate(output_dir.join("config.json"), TemplateRenderer::new_format(Format::Json))?; +``` + +## Caveats + +At this time, [arrays](../array.md) and [objects](../object.md) do not support default values, and +will render `[]` and `{}` respectively. + +Furthermore, [enums](../enum.md) and [unions](../union.md) only support default values when +explicitly marked as such. For example, with `#[default]`. + +And lastly, when we're unsure of what to render for a value, we'll render `null`. This isn't a valid +value for TOML, and may not be what you expect. + +## Example output + +Given the following type: + +```rust +#[derive(Config)] +struct ServerConfig { + /// The base URL to serve from. + #[setting(default = "/")] + pub base_url: String, + + /// The default port to listen on. + #[setting(default = 8080, env = "PORT")] + pub port: usize, +} +``` + +Would render the following formats: + + + + + + + + + + +
JSONTOML
+ +```json +{ + // The base URL to serve from. + "base_url": "/", + + // The default port to listen on. + // @envvar PORT + "port": 8080 +} +``` + + + +```toml +# The base URL to serve from. +base_url = "/" + +# The default port to listen on. +# @envvar PORT +port = 8080 +``` + +
+ +
+ + + + + + + + +
YAML
+ +```yaml +# The base URL to serve from. +base_url: "/" + +# The default port to listen on. +# @envvar PORT +port: 8080 +``` + +
+ +> Applying the desired casing for field names should be done with `rename_all` on the container. + +## Options + +Custom options can be passed to the renderer using +[`TemplateOptions`](https://docs.rs/schematic/latest/schematic/schema/template/struct.TemplateOptions.html). + +```rust +TemplateRenderer::new(Format::Json, TemplateOptions { + // ... + ..TemplateOptions::default() +}); +``` + +> The `format` option is required! + +### Indentation + +The indentation of the generated template can be customized using the `indent_char` option. By +default this is 2 spaces (` `). + +```rust +TemplateOptions { + // ... + indent_char: "\t".into(), +} +``` + +The spacing between fields can also be toggled with the `newline_between_fields` option. By default +this is enabled, which adds a newline between each field. + +```rust +TemplateOptions { + // ... + newline_between_fields: false, +} +``` + +### Comments + +All Rust doc comments (`///`) are rendered as comments above each field in the template. This can be +disabled with the `comments` option. + +```rust +TemplateOptions { + // ... + comments: false, +} +``` + +### Header and footer + +The `header` and `footer` options can be customized to add additional content to the top and bottom +of the rendered template respectively. + +```rust +TemplateOptions { + // ... + header: "$schema: \"https://example.com/schema.json\"\n\n".into(), + footer: "\n\n# Learn more: https://example.com".into(), +} +``` + +### Field display + +By default all non-skipped fields in the root document (struct) are rendered in the template. If +you'd like to hide certain fields from being rendered, you can use the `hide_fields` option. This +option accepts a list of field names and also supports dot-notation for nested fields. + +```rust +TemplateOptions { + // ... + hide_fields: vec!["key".into(), "nested.key".into()], +} +``` + +Additionally, if you'd like to render a field but have it commented out by default, use the +`comment_fields` option instead. This also supports dot-notation for nested fields. + +```rust +TemplateOptions { + // ... + comment_fields: vec!["key".into(), "nested.key".into()], +} +``` diff --git a/book/src/schema/generator/typescript.md b/book/src/schema/generator/typescript.md index 8cec031f..8e4ead70 100644 --- a/book/src/schema/generator/typescript.md +++ b/book/src/schema/generator/typescript.md @@ -33,8 +33,8 @@ TypeScriptRenderer::new(TypeScriptOptions { ### Indentation -The indentation of the generated TypeScript code can be customized using the `indent_char` field. By -default this is a tab (`\t`). +The indentation of the generated TypeScript code can be customized using the `indent_char` option. +By default this is a tab (`\t`). ```rust TypeScriptOptions { @@ -45,7 +45,7 @@ TypeScriptOptions { ### Enum types -[Enum types](../enum.md) can be rendered in a format of your choice using the `enum_format` field +[Enum types](../enum.md) can be rendered in a format of your choice using the `enum_format` option and the [`EnumFormat`](https://docs.rs/schematic/latest/schematic/schema/typescript/enum.EnumFormat.html) enum. By default enums are rendered as TypeScript string unions, but can be rendered as TypeScript @@ -70,7 +70,7 @@ export enum LogLevel { } ``` -Furthermore, the `const_enum` field can be enabled to render `const enum` types instead of `enum` +Furthermore, the `const_enum` option can be enabled to render `const enum` types instead of `enum` types. This does not apply when `EnumFormat::Union` is used. ```rust @@ -91,7 +91,7 @@ export enum LogLevel {} ### Object types [Struct types](../struct.md) can be rendered as either TypeScript interfaces or type aliases using -the `object_format` field and the +the `object_format` option and the [`ObjectFormat`](https://docs.rs/schematic/latest/schematic/schema/typescript/enum.ObjectFormat.html) enum. By default structs are rendered as TypeScript interfaces. @@ -129,8 +129,8 @@ export interface User { ``` Depending on your use case, this may not be desirable. If so, you can enable the -`disable_references` field, which disables references entirely, and inlines all type information. So -the example above would become: +`disable_references` option, which disables references entirely, and inlines all type information. +So the example above would become: ```rust TypeScriptOptions { @@ -147,7 +147,7 @@ export interface User { } ``` -Additionally, the `exclude_references` field can be used to exclude a type reference by name +Additionally, the `exclude_references` option can be used to exclude a type reference by name entirely from the output, as demonstrated below. ```rust @@ -166,7 +166,7 @@ export interface User { ### Importing external types For better interoperability, you can import external types from other TypeScript modules using the -`external_types` field, which is a map of file paths (relative from the output location) to a list +`external_types` option, which is a map of file paths (relative from the output location) to a list of types to import from that file. This is useful if: - You have existing types that aren't generated and want to reference. diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index b410dc83..fb73ac99 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -62,6 +62,7 @@ config = [ json = ["dep:serde_json"] json_schema = ["dep:schemars", "json", "schema"] schema = ["dep:indexmap", "schematic_macros/schema"] +template = [] toml = ["dep:toml"] typescript = ["schema"] url = ["dep:reqwest"] @@ -88,6 +89,7 @@ schematic = { path = ".", features = [ "json_schema", "json", "schema", + "template", "toml", "typescript", "type_chrono", diff --git a/crates/config/src/config/format.rs b/crates/config/src/config/format.rs index fe5461ac..0a8c1e60 100644 --- a/crates/config/src/config/format.rs +++ b/crates/config/src/config/format.rs @@ -1,7 +1,8 @@ use crate::config::errors::{ConfigError, ParserError}; use miette::{SourceOffset, SourceSpan}; -use serde::Deserialize; -use serde::{de::DeserializeOwned, Serialize}; +use serde::de::DeserializeOwned; + +pub use crate::format::Format; fn create_span(content: &str, line: usize, column: usize) -> SourceSpan { let offset = SourceOffset::from_location(content, line, column).offset(); @@ -10,25 +11,6 @@ fn create_span(content: &str, line: usize, column: usize) -> SourceSpan { (offset, length).into() } -/// Supported source configuration formats. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Format { - // This is to simply handle the use case when no features are - // enabled. If this doesn't exist, Rust errors with no variants. - #[doc(hidden)] - None, - - #[cfg(feature = "json")] - Json, - - #[cfg(feature = "toml")] - Toml, - - #[cfg(feature = "yaml")] - Yaml, -} - impl Format { /// Detects a format from a provided value, either a file path or URL, by /// checking for a supported file extension. diff --git a/crates/config/src/config/mod.rs b/crates/config/src/config/mod.rs index ac18d64e..67cd38f8 100644 --- a/crates/config/src/config/mod.rs +++ b/crates/config/src/config/mod.rs @@ -11,7 +11,6 @@ mod validator; pub use cacher::*; pub use configs::*; pub use errors::*; -pub use format::*; pub use layer::*; pub use loader::*; pub use path::*; diff --git a/crates/config/src/format.rs b/crates/config/src/format.rs new file mode 100644 index 00000000..e41708ae --- /dev/null +++ b/crates/config/src/format.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +/// Supported source configuration formats. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + // This is to simply handle the use case when no features are + // enabled. If this doesn't exist, Rust errors with no variants. + #[doc(hidden)] + #[default] + None, + + #[cfg(feature = "json")] + Json, + + #[cfg(feature = "toml")] + Toml, + + #[cfg(feature = "yaml")] + Yaml, +} + +impl Format { + pub fn is_json(&self) -> bool { + #[cfg(feature = "json")] + { + matches!(self, Format::Json) + } + #[cfg(not(feature = "json"))] + { + false + } + } + + pub fn is_toml(&self) -> bool { + #[cfg(feature = "toml")] + { + matches!(self, Format::Toml) + } + #[cfg(not(feature = "toml"))] + { + false + } + } + + pub fn is_yaml(&self) -> bool { + #[cfg(feature = "yaml")] + { + matches!(self, Format::Yaml) + } + #[cfg(not(feature = "yaml"))] + { + false + } + } +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index f26fa215..c087e265 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,5 @@ #![allow(clippy::result_large_err)] +mod format; #[cfg(feature = "config")] mod config; @@ -30,5 +31,6 @@ pub use starbase_styles::color; #[cfg(feature = "config")] pub use config::*; +pub use format::*; pub use schematic_macros::*; pub use schematic_types::{SchemaField, SchemaType, Schematic}; diff --git a/crates/config/src/schema/mod.rs b/crates/config/src/schema/mod.rs index 111de339..86d89907 100644 --- a/crates/config/src/schema/mod.rs +++ b/crates/config/src/schema/mod.rs @@ -11,6 +11,10 @@ pub use schematic_types::*; #[cfg(feature = "json_schema")] pub use renderers::json_schema; +/// Renders file templates. +#[cfg(feature = "template")] +pub use renderers::template; + /// Renders TypeScript types. #[cfg(feature = "typescript")] pub use renderers::typescript; diff --git a/crates/config/src/schema/renderers/json_schema.rs b/crates/config/src/schema/renderers/json_schema.rs index b7e5d414..242576c1 100644 --- a/crates/config/src/schema/renderers/json_schema.rs +++ b/crates/config/src/schema/renderers/json_schema.rs @@ -267,13 +267,11 @@ impl SchemaRenderer for JsonSchemaRenderer { continue; } - let name = field.name.clone().unwrap(); - if !field.optional { - required.insert(name.clone()); + required.insert(field.name.clone()); } - properties.insert(name, self.create_schema_from_field(field)?); + properties.insert(field.name.clone(), self.create_schema_from_field(field)?); } let data = SchemaObject { diff --git a/crates/config/src/schema/renderers/mod.rs b/crates/config/src/schema/renderers/mod.rs index f4d04968..52c5007b 100644 --- a/crates/config/src/schema/renderers/mod.rs +++ b/crates/config/src/schema/renderers/mod.rs @@ -1,5 +1,8 @@ #[cfg(feature = "json_schema")] pub mod json_schema; +#[cfg(feature = "template")] +pub mod template; + #[cfg(feature = "typescript")] pub mod typescript; diff --git a/crates/config/src/schema/renderers/template.rs b/crates/config/src/schema/renderers/template.rs new file mode 100644 index 00000000..eacce157 --- /dev/null +++ b/crates/config/src/schema/renderers/template.rs @@ -0,0 +1,466 @@ +use crate::format::Format; +use crate::schema::{RenderResult, SchemaRenderer}; +use indexmap::IndexMap; +use miette::miette; +use schematic_types::*; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// Options to control the rendered template. +pub struct TemplateOptions { + /// Include field comments in output. + pub comments: bool, + + /// List of field names to render but comment out. Supports dot notation. + pub comment_fields: Vec, + + /// Default values for each field within the root struct. + pub default_values: HashMap, + + /// Content to append to the bottom of the output. + pub footer: String, + + /// Content to prepend to the top of the output. + pub header: String, + + /// List of field names to not render. Supports dot notation. + pub hide_fields: Vec, + + /// Character(s) to use for indentation. + pub indent_char: String, + + /// Insert an extra newline between fields. + pub newline_between_fields: bool, +} + +impl Default for TemplateOptions { + fn default() -> Self { + Self { + comments: true, + comment_fields: vec![], + default_values: HashMap::new(), + footer: String::new(), + header: String::new(), + hide_fields: vec![], + indent_char: " ".into(), + newline_between_fields: true, + } + } +} + +fn lit_to_string(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 is_nested_type(schema: &SchemaType) -> bool { + match schema { + SchemaType::Struct(_) => true, + SchemaType::Union(uni) => { + if uni.is_nullable() && uni.variants_types.len() == 2 { + uni.variants_types + .iter() + .find(|v| !v.is_null()) + .is_some_and(|v| is_nested_type(v)) + } else { + false + } + } + _ => false, + } +} + +/// Renders template files from a schema. +pub struct TemplateRenderer { + depth: usize, + format: Format, + options: TemplateOptions, + stack: VecDeque, +} + +impl TemplateRenderer { + pub fn new_format(format: Format) -> Self { + Self::new(format, TemplateOptions::default()) + } + + pub fn new(format: Format, options: TemplateOptions) -> Self { + Self { + depth: 0, + format, + options, + stack: VecDeque::new(), + } + } + + fn indent(&self) -> String { + if self.depth == 0 { + String::new() + } else { + self.options.indent_char.repeat(self.depth) + } + } + + fn gap(&self) -> &str { + if self.options.newline_between_fields { + "\n\n" + } else { + "\n" + } + } + + fn create_comment(&self, field: &SchemaField) -> String { + if !self.options.comments + || field.description.is_none() + || field + .description + .as_ref() + .is_some_and(|desc| desc.is_empty()) + { + return String::new(); + } + + let mut lines = vec![]; + let indent = self.indent(); + let prefix = self.get_comment_prefix(); + + let mut push = |line: String| { + lines.push(format!("{indent}{prefix}{}", line)); + }; + + if let Some(comment) = &field.description { + comment + .trim() + .split('\n') + .for_each(|c| push(c.trim().to_owned())); + } + + if let Some(deprecated) = &field.deprecated { + push(if deprecated.is_empty() { + "@deprecated".into() + } else { + format!("@deprecated {}", deprecated) + }); + } + + if let Some(env_var) = &field.env_var { + push(format!("@envvar {}", env_var)); + } + + let mut out = lines.join("\n"); + out.push('\n'); + out + } + + fn create_field(&self, field: &SchemaField, property: String) -> String { + let key = self.get_stack_key(); + + format!( + "{}{}{}{}", + self.create_comment(field), + self.indent(), + if self.options.comment_fields.contains(&key) { + self.get_comment_prefix() + } else { + "" + }, + property + ) + } + + fn get_comment_prefix(&self) -> &str { + if self.format.is_json() { + "// " + } else { + "# " + } + } + + fn get_field_value(&mut self, schema: &SchemaType) -> miette::Result { + let key = self.get_stack_key(); + + if let Some(default) = self.options.default_values.remove(&key) { + return self.render_schema(&default); + } + + self.render_schema(schema) + } + + fn get_stack_key(&self) -> String { + let mut key = String::new(); + let last_index = self.stack.len() - 1; + + for (index, item) in self.stack.iter().enumerate() { + key.push_str(item); + + if index != last_index { + key.push('.'); + } + } + + key + } + + fn is_hidden(&self, field: &SchemaField) -> bool { + let key = self.get_stack_key(); + + field.hidden || self.options.hide_fields.contains(&key) + } +} + +impl SchemaRenderer for TemplateRenderer { + fn is_reference(&self, _name: &str) -> bool { + false + } + + fn render_array(&mut self, _array: &ArrayType) -> RenderResult { + Ok("[]".into()) + } + + fn render_boolean(&mut self, boolean: &BooleanType) -> RenderResult { + if let Some(default) = &boolean.default { + return Ok(lit_to_string(default)); + } + + Ok("false".into()) + } + + fn render_enum(&mut self, enu: &EnumType) -> RenderResult { + if let Some(index) = &enu.default_index { + if let Some(value) = enu.values.get(*index) { + return Ok(lit_to_string(value)); + } + } + + self.render_null() + } + + fn render_float(&mut self, float: &FloatType) -> RenderResult { + if let Some(default) = &float.default { + return Ok(lit_to_string(default)); + } + + Ok("0.0".into()) + } + + fn render_integer(&mut self, integer: &IntegerType) -> RenderResult { + if let Some(default) = &integer.default { + return Ok(lit_to_string(default)); + } + + Ok("0".into()) + } + + fn render_literal(&mut self, literal: &LiteralType) -> RenderResult { + if let Some(value) = &literal.value { + return Ok(lit_to_string(value)); + } + + self.render_null() + } + + fn render_null(&mut self) -> RenderResult { + Ok("null".into()) + } + + fn render_object(&mut self, _object: &ObjectType) -> RenderResult { + Ok("{}".into()) + } + + fn render_reference(&mut self, reference: &str) -> RenderResult { + Ok(reference.into()) + } + + fn render_string(&mut self, string: &StringType) -> RenderResult { + if let Some(default) = &string.default { + return Ok(lit_to_string(default)); + } + + Ok("\"\"".into()) + } + + fn render_struct(&mut self, structure: &StructType) -> RenderResult { + #[cfg(feature = "json")] + { + if self.format.is_json() { + let mut out = vec![]; + + self.depth += 1; + + for field in &structure.fields { + self.stack.push_back(field.name.clone()); + + if !self.is_hidden(field) { + let prop = format!( + "\"{}\": {},", + field.name, + self.get_field_value(&field.type_of)?, + ); + + out.push(self.create_field(field, prop)); + } + + self.stack.pop_back(); + } + + self.depth -= 1; + + return Ok(format!("{{\n{}\n{}}}", out.join(self.gap()), self.indent())); + } + } + + #[cfg(feature = "toml")] + { + if self.format.is_toml() { + let mut out = vec![]; + let mut structs = vec![]; + + for field in &structure.fields { + // Nested structs have weird syntax, so render them + // at the bottom after other fields + if is_nested_type(&field.type_of) { + structs.push(field); + continue; + } + + self.stack.push_back(field.name.clone()); + + if !self.is_hidden(field) { + let prop = + format!("{} = {}", field.name, self.get_field_value(&field.type_of)?,); + + out.push(self.create_field(field, prop)); + } + + self.stack.pop_back(); + } + + for field in structs { + self.stack.push_back(field.name.clone()); + + if !self.is_hidden(field) { + out.push(format!( + "{}{}[{}]\n{}", + if self.options.newline_between_fields && self.stack.len() == 1 { + "" + } else { + "\n" + }, + self.create_comment(field), + self.stack.iter().cloned().collect::>().join("."), + self.get_field_value(&field.type_of)?, + )); + } + + self.stack.pop_back(); + } + + return Ok(out.join(self.gap())); + } + } + + #[cfg(feature = "yaml")] + { + if self.format.is_yaml() { + let mut out = vec![]; + + for field in &structure.fields { + self.stack.push_back(field.name.clone()); + + if self.is_hidden(field) { + self.stack.pop_back(); + continue; + } + + let is_nested = is_nested_type(&field.type_of); + + if is_nested { + self.depth += 1; + } + + let value = self.get_field_value(&field.type_of)?; + + if is_nested { + self.depth -= 1; + } + + let prop = format!( + "{}:{}{}", + field.name, + if is_nested { "\n" } else { " " }, + value + ); + + out.push(self.create_field(field, prop)); + + self.stack.pop_back(); + } + + return Ok(out.join(self.gap())); + } + } + + Ok("".into()) + } + + fn render_tuple(&mut self, tuple: &TupleType) -> 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) -> RenderResult { + if let Some(index) = &uni.default_index { + if let Some(variant) = uni.variants_types.get(*index) { + return self.render_schema(variant); + } + } + + // We have a nullable type, so render the non-null value + if uni.is_nullable() { + if let Some(variant) = uni.variants_types.iter().find(|v| !v.is_null()) { + return self.render_schema(variant); + } + } + + self.render_null() + } + + fn render_unknown(&mut self) -> RenderResult { + self.render_null() + } + + fn render( + &mut self, + schemas: &IndexMap, + _references: &HashSet, + ) -> RenderResult { + let Some(schema) = schemas.values().last() else { + return Err(miette!( + "At least 1 schema is required to generate a template." + )); + }; + + let SchemaType::Struct(schema) = schema else { + return Err(miette!("The last registered schema must be a struct type.")); + }; + + let template = self.render_struct(schema)?; + + let mut output = format!("{}{}{}", self.options.header, template, self.options.footer); + + if self.format.is_toml() || self.format.is_yaml() { + output.push('\n'); + } + + Ok(output) + } +} diff --git a/crates/config/src/schema/renderers/typescript.rs b/crates/config/src/schema/renderers/typescript.rs index 3539547d..03f3b6e3 100644 --- a/crates/config/src/schema/renderers/typescript.rs +++ b/crates/config/src/schema/renderers/typescript.rs @@ -159,26 +159,24 @@ impl TypeScriptRenderer { continue; } - if let Some(variant_name) = &variant.name { - let field = if matches!(self.options.enum_format, EnumFormat::ValuedEnum) { - format!( - "{}{} = {},", - indent, - variant_name, - self.render_schema(&variant.type_of)? - ) - } else { - format!("{}{},", indent, variant_name) - }; - - let mut tags = vec![]; + let field = if matches!(self.options.enum_format, EnumFormat::ValuedEnum) { + format!( + "{}{} = {},", + indent, + variant.name, + self.render_schema(&variant.type_of)? + ) + } else { + format!("{}{},", indent, variant.name) + }; - if let Some(default) = variant.type_of.get_default() { - tags.push(format!("@default {}", self.lit_to_string(default))); - } + let mut tags = vec![]; - out.push(self.wrap_in_comment(variant.description.as_ref(), tags, field)); + if let Some(default) = variant.type_of.get_default() { + tags.push(format!("@default {}", self.lit_to_string(default))); } + + out.push(self.wrap_in_comment(variant.description.as_ref(), tags, field)); } self.depth -= 1; @@ -350,7 +348,7 @@ impl SchemaRenderer for TypeScriptRenderer { continue; } - let mut row = format!("{}{}", indent, field.name.as_ref().unwrap()); + let mut row = format!("{}{}", indent, field.name); if field.optional { row.push_str("?: "); diff --git a/crates/config/tests/generator_test.rs b/crates/config/tests/generator_test.rs index 2ab8cb4e..ab7db533 100644 --- a/crates/config/tests/generator_test.rs +++ b/crates/config/tests/generator_test.rs @@ -1,5 +1,6 @@ #![allow(dead_code, deprecated)] +use schematic::schema::template::TemplateOptions; use schematic::schema::SchemaGenerator; use schematic::*; use starbase_sandbox::{assert_snapshot, create_empty_sandbox}; @@ -21,7 +22,9 @@ derive_enum!( /// Some comment. #[derive(Clone, Config)] pub struct AnotherConfig { + /// An optional string. opt: Option, + /// An optional enum. enums: Option, } @@ -57,12 +60,77 @@ struct GenConfig { yaml_value: serde_yaml::Value, } +/// Some comment. +#[derive(Clone, Config)] +pub struct TwoDepthConfig { + /// An optional string. + opt: Option, + skipped: String, +} + +/// Some comment. +#[derive(Clone, Config)] +pub struct OneDepthConfig { + /// This is another nested field. + #[setting(nested)] + two: TwoDepthConfig, + #[setting(skip)] + skipped: String, +} + +#[derive(Clone, Config)] +struct TemplateConfig { + /// This is a boolean with a medium length description. + #[setting(env = "TEMPLATE_BOOLEAN")] + boolean: bool, + /// This is a string. + #[setting(default = "abc")] + string: String, + /// This is a number with a long description. + /// This is a number with a long description. + number: usize, + /// This is a float thats deprecated. + #[deprecated] + float32: f32, + /// This is a float. + #[setting(default = 1.23)] + float64: f64, + /// This is a list of strings. + vector: Vec, + /// This is a map of numbers. + map: HashMap, + /// This is an enum with a medium length description and deprecated. + #[deprecated = "Dont use enums!"] + enums: BasicEnum, + /// This is a nested struct with its own fields. + #[setting(nested)] + nested: AnotherConfig, + /// This is a nested struct with its own fields. + #[setting(nested)] + one: OneDepthConfig, + skipped: String, +} + fn create_generator() -> SchemaGenerator { let mut generator = SchemaGenerator::default(); generator.add::(); generator } +fn create_template_generator() -> SchemaGenerator { + let mut generator = SchemaGenerator::default(); + generator.add::(); + generator +} + +fn create_template_options() -> TemplateOptions { + TemplateOptions { + comment_fields: vec!["float32".into(), "map".into()], + hide_fields: vec!["skipped".into(), "one.two.skipped".into()], + ..TemplateOptions::default() + } +} + #[cfg(feature = "json_schema")] mod json_schema { use super::*; @@ -81,6 +149,69 @@ mod json_schema { } } +#[cfg(all(feature = "template", feature = "json"))] +mod template_json { + use super::*; + use schematic::schema::template::*; + + #[test] + fn defaults() { + let sandbox = create_empty_sandbox(); + let file = sandbox.path().join("schema.json"); + + create_template_generator() + .generate( + &file, + TemplateRenderer::new(Format::Json, create_template_options()), + ) + .unwrap(); + + assert_snapshot!(fs::read_to_string(file).unwrap()); + } +} + +#[cfg(all(feature = "template", feature = "toml"))] +mod template_toml { + use super::*; + use schematic::schema::template::*; + + #[test] + fn defaults() { + let sandbox = create_empty_sandbox(); + let file = sandbox.path().join("schema.toml"); + + create_template_generator() + .generate( + &file, + TemplateRenderer::new(Format::Toml, create_template_options()), + ) + .unwrap(); + + assert_snapshot!(fs::read_to_string(file).unwrap()); + } +} + +#[cfg(all(feature = "template", feature = "yaml"))] +mod template_yaml { + use super::*; + use schematic::schema::template::*; + + #[test] + fn defaults() { + let sandbox = create_empty_sandbox(); + let file = sandbox.path().join("schema.yaml"); + + create_template_generator() + .generate( + &file, + TemplateRenderer::new(Format::Yaml, create_template_options()), + ) + .unwrap(); + + assert_snapshot!(fs::read_to_string(file).unwrap()); + } +} + #[cfg(feature = "typescript")] mod typescript { use super::*; diff --git a/crates/config/tests/snapshots/generator_test__json_schema__defaults.snap b/crates/config/tests/snapshots/generator_test__json_schema__defaults.snap index 2810d31f..d1715709 100644 --- a/crates/config/tests/snapshots/generator_test__json_schema__defaults.snap +++ b/crates/config/tests/snapshots/generator_test__json_schema__defaults.snap @@ -50,6 +50,7 @@ expression: "fs::read_to_string(file).unwrap()" "format": "decimal" }, "enums": { + "default": "foo", "allOf": [ { "$ref": "#/definitions/BasicEnum" @@ -195,6 +196,8 @@ expression: "fs::read_to_string(file).unwrap()" ], "properties": { "enums": { + "description": "An optional enum.", + "default": "foo", "anyOf": [ { "$ref": "#/definitions/BasicEnum" @@ -205,6 +208,7 @@ expression: "fs::read_to_string(file).unwrap()" ] }, "opt": { + "description": "An optional string.", "anyOf": [ { "type": "string" diff --git a/crates/config/tests/snapshots/generator_test__template_json__defaults.snap b/crates/config/tests/snapshots/generator_test__template_json__defaults.snap new file mode 100644 index 00000000..0be3573a --- /dev/null +++ b/crates/config/tests/snapshots/generator_test__template_json__defaults.snap @@ -0,0 +1,52 @@ +--- +source: crates/config/tests/generator_test.rs +expression: "fs::read_to_string(file).unwrap()" +--- +{ + // This is a boolean with a medium length description. + // @envvar TEMPLATE_BOOLEAN + "boolean": false, + + // This is an enum with a medium length description and deprecated. + // @deprecated Dont use enums! + "enums": "foo", + + // This is a float thats deprecated. + // @deprecated + // "float32": 0.0, + + // This is a float. + "float64": 1.23, + + // This is a map of numbers. + // "map": {}, + + // This is a nested struct with its own fields. + "nested": { + // An optional string. + "opt": "", + + // An optional enum. + "enums": "foo", + }, + + // This is a number with a long description. + // This is a number with a long description. + "number": 0, + + // This is a nested struct with its own fields. + "one": { + // This is another nested field. + "two": { + // An optional string. + "opt": "", + }, + }, + + // This is a string. + "string": "abc", + + // This is a list of strings. + "vector": [], +} + diff --git a/crates/config/tests/snapshots/generator_test__template_toml__defaults.snap b/crates/config/tests/snapshots/generator_test__template_toml__defaults.snap new file mode 100644 index 00000000..77de7442 --- /dev/null +++ b/crates/config/tests/snapshots/generator_test__template_toml__defaults.snap @@ -0,0 +1,49 @@ +--- +source: crates/config/tests/generator_test.rs +expression: "fs::read_to_string(file).unwrap()" +--- +# This is a boolean with a medium length description. +# @envvar TEMPLATE_BOOLEAN +boolean = false + +# This is an enum with a medium length description and deprecated. +# @deprecated Dont use enums! +enums = "foo" + +# This is a float thats deprecated. +# @deprecated +# float32 = 0.0 + +# This is a float. +float64 = 1.23 + +# This is a map of numbers. +# map = {} + +# This is a number with a long description. +# This is a number with a long description. +number = 0 + +# This is a string. +string = "abc" + +# This is a list of strings. +vector = [] + +# This is a nested struct with its own fields. +[nested] +# An optional string. +opt = "" + +# An optional enum. +enums = "foo" + +# This is a nested struct with its own fields. +[one] + +# This is another nested field. +[one.two] +# An optional string. +opt = "" + + diff --git a/crates/config/tests/snapshots/generator_test__template_yaml__defaults.snap b/crates/config/tests/snapshots/generator_test__template_yaml__defaults.snap new file mode 100644 index 00000000..25ad4600 --- /dev/null +++ b/crates/config/tests/snapshots/generator_test__template_yaml__defaults.snap @@ -0,0 +1,48 @@ +--- +source: crates/config/tests/generator_test.rs +expression: "fs::read_to_string(file).unwrap()" +--- +# This is a boolean with a medium length description. +# @envvar TEMPLATE_BOOLEAN +boolean: false + +# This is an enum with a medium length description and deprecated. +# @deprecated Dont use enums! +enums: "foo" + +# This is a float thats deprecated. +# @deprecated +# float32: 0.0 + +# This is a float. +float64: 1.23 + +# This is a map of numbers. +# map: {} + +# This is a nested struct with its own fields. +nested: + # An optional string. + opt: "" + + # An optional enum. + enums: "foo" + +# This is a number with a long description. +# This is a number with a long description. +number: 0 + +# This is a nested struct with its own fields. +one: + # This is another nested field. + two: + # An optional string. + opt: "" + +# This is a string. +string: "abc" + +# This is a list of strings. +vector: [] + + diff --git a/crates/config/tests/snapshots/generator_test__typescript__const_enums.snap b/crates/config/tests/snapshots/generator_test__typescript__const_enums.snap index faa19d47..bb477249 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__const_enums.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__const_enums.snap @@ -14,7 +14,13 @@ export const enum BasicEnum { /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -23,6 +29,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__defaults.snap b/crates/config/tests/snapshots/generator_test__typescript__defaults.snap index 968923e6..89dfb4fb 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__defaults.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__defaults.snap @@ -10,7 +10,13 @@ export type BasicEnum = 'foo' | 'bar' | 'baz'; /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -19,6 +25,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__enums.snap b/crates/config/tests/snapshots/generator_test__typescript__enums.snap index 04b87646..73e31bd0 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__enums.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__enums.snap @@ -14,7 +14,13 @@ export enum BasicEnum { /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -23,6 +29,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__exclude_refs.snap b/crates/config/tests/snapshots/generator_test__typescript__exclude_refs.snap index 915ee83e..a7dcbf18 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__exclude_refs.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__exclude_refs.snap @@ -1,6 +1,6 @@ --- source: crates/config/tests/generator_test.rs -expression: "generate(TypeScriptOptions {\n exclude_references: HashSet::from_iter([\"BasicEnum\".into(),\n \"AnotherType\".into()]),\n ..TypeScriptOptions::default()\n })" +expression: "generate(TypeScriptOptions {\n exclude_references: vec![\"BasicEnum\".into(), \"AnotherType\".into()],\n ..TypeScriptOptions::default()\n })" --- // Automatically generated by schematic. DO NOT MODIFY! @@ -8,7 +8,13 @@ expression: "generate(TypeScriptOptions {\n exclude_references: HashSet:: /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -17,6 +23,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__external_types.snap b/crates/config/tests/snapshots/generator_test__typescript__external_types.snap index a3e34751..1568ff99 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__external_types.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__external_types.snap @@ -1,6 +1,6 @@ --- source: crates/config/tests/generator_test.rs -expression: "generate(TypeScriptOptions {\n external_types: HashMap::from_iter([(\"./externals\".into(),\n HashSet::from_iter([\"BasicEnum\".into(),\n \"AnotherType\".into()]))]),\n ..TypeScriptOptions::default()\n })" +expression: "generate(TypeScriptOptions {\n external_types: HashMap::from_iter([(\"./externals\".into(),\n vec![\"BasicEnum\".into(), \"AnotherType\".into()])]),\n ..TypeScriptOptions::default()\n })" --- // Automatically generated by schematic. DO NOT MODIFY! @@ -12,7 +12,13 @@ export type BasicEnum = 'foo' | 'bar' | 'baz'; /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -21,6 +27,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__no_refs.snap b/crates/config/tests/snapshots/generator_test__typescript__no_refs.snap index 2fe80fcc..2c0ce9f0 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__no_refs.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__no_refs.snap @@ -10,7 +10,13 @@ export type BasicEnum = 'foo' | 'bar' | 'baz'; /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: 'foo' | 'bar' | 'baz' | null; + /** An optional string. */ opt: string | null; } @@ -19,6 +25,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: 'foo' | 'bar' | 'baz'; float32: number; float64: number; @@ -27,7 +34,13 @@ export interface GenConfig { locator: string | null; map: Record; nested: { + /** An optional string. */ opt: string | null; + /** + * An optional enum. + * + * @default 'foo' + */ enums: 'foo' | 'bar' | 'baz' | null; }; number: number; diff --git a/crates/config/tests/snapshots/generator_test__typescript__object_aliases.snap b/crates/config/tests/snapshots/generator_test__typescript__object_aliases.snap index d2239598..1616ed98 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__object_aliases.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__object_aliases.snap @@ -10,7 +10,13 @@ export type BasicEnum = 'foo' | 'bar' | 'baz'; /** Some comment. */ export type AnotherConfig = { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null, + /** An optional string. */ opt: string | null, }; @@ -19,6 +25,7 @@ export type GenConfig = { date: string, datetime: string, decimal: string, + /** @default 'foo' */ enums: BasicEnum, float32: number, float64: number, diff --git a/crates/config/tests/snapshots/generator_test__typescript__value_enums.snap b/crates/config/tests/snapshots/generator_test__typescript__value_enums.snap index 2c6bd86d..5cc1ac43 100644 --- a/crates/config/tests/snapshots/generator_test__typescript__value_enums.snap +++ b/crates/config/tests/snapshots/generator_test__typescript__value_enums.snap @@ -14,7 +14,13 @@ export enum BasicEnum { /** Some comment. */ export interface AnotherConfig { + /** + * An optional enum. + * + * @default 'foo' + */ enums: BasicEnum | null; + /** An optional string. */ opt: string | null; } @@ -23,6 +29,7 @@ export interface GenConfig { date: string; datetime: string; decimal: string; + /** @default 'foo' */ enums: BasicEnum; float32: number; float64: number; diff --git a/crates/config/tests/snapshots/macros_test__generates_json_schema.snap b/crates/config/tests/snapshots/macros_test__generates_json_schema.snap index 5a5f52c1..25544218 100644 --- a/crates/config/tests/snapshots/macros_test__generates_json_schema.snap +++ b/crates/config/tests/snapshots/macros_test__generates_json_schema.snap @@ -144,6 +144,7 @@ expression: "std::fs::read_to_string(file).unwrap()" "type": "boolean" }, "enums": { + "default": "a", "allOf": [ { "$ref": "#/definitions/SomeEnum" @@ -413,6 +414,7 @@ expression: "std::fs::read_to_string(file).unwrap()" ] }, "enums": { + "default": "a", "anyOf": [ { "$ref": "#/definitions/SomeEnum" @@ -656,6 +658,7 @@ expression: "std::fs::read_to_string(file).unwrap()" ] }, "enums": { + "default": "a", "anyOf": [ { "$ref": "#/definitions/SomeEnum" @@ -855,6 +858,7 @@ expression: "std::fs::read_to_string(file).unwrap()" "type": "boolean" }, "enums": { + "default": "a", "allOf": [ { "$ref": "#/definitions/SomeEnum" diff --git a/crates/config/tests/snapshots/macros_test__generates_typescript-2.snap b/crates/config/tests/snapshots/macros_test__generates_typescript-2.snap index d8863d5a..6e705e45 100644 --- a/crates/config/tests/snapshots/macros_test__generates_typescript-2.snap +++ b/crates/config/tests/snapshots/macros_test__generates_typescript-2.snap @@ -40,6 +40,7 @@ export const enum Aliased { export type ValueTypes = { boolean: boolean, + /** @default 'a' */ enums: SomeEnum, map: Record, number: number, @@ -59,6 +60,7 @@ export type DefaultValues = { /** @default true */ boolean: boolean, booleanFn: boolean, + /** @default 'a' */ enums: SomeEnum, /** @default 'foo.json' */ fileString: string, @@ -160,6 +162,7 @@ export type PartialDefaultValues = { /** @default true */ boolean?: boolean | null, booleanFn?: boolean | null, + /** @default 'a' */ enums?: SomeEnum | null, /** @default 'foo.json' */ fileString?: string | null, @@ -182,6 +185,7 @@ export type PartialDefaultValues = { export type PartialValueTypes = { boolean?: boolean | null, + /** @default 'a' */ enums?: SomeEnum | null, map?: Record | null, number?: number | null, diff --git a/crates/config/tests/snapshots/macros_test__generates_typescript.snap b/crates/config/tests/snapshots/macros_test__generates_typescript.snap index 5cec69b3..8e01a405 100644 --- a/crates/config/tests/snapshots/macros_test__generates_typescript.snap +++ b/crates/config/tests/snapshots/macros_test__generates_typescript.snap @@ -18,6 +18,7 @@ export type Aliased = 'foo' | 'bar' | 'baz'; export interface ValueTypes { boolean: boolean; + /** @default 'a' */ enums: SomeEnum; map: Record; number: number; @@ -37,6 +38,7 @@ export interface DefaultValues { /** @default true */ boolean: boolean; booleanFn: boolean; + /** @default 'a' */ enums: SomeEnum; /** @default 'foo.json' */ fileString: string; @@ -138,6 +140,7 @@ export interface PartialDefaultValues { /** @default true */ boolean?: boolean | null; booleanFn?: boolean | null; + /** @default 'a' */ enums?: SomeEnum | null; /** @default 'foo.json' */ fileString?: string | null; @@ -160,6 +163,7 @@ export interface PartialDefaultValues { export interface PartialValueTypes { boolean?: boolean | null; + /** @default 'a' */ enums?: SomeEnum | null; map?: Record | null; number?: number | null; diff --git a/crates/macros/src/common/container.rs b/crates/macros/src/common/container.rs index 7176ef19..f44213db 100644 --- a/crates/macros/src/common/container.rs +++ b/crates/macros/src/common/container.rs @@ -1,4 +1,5 @@ use crate::common::{Field, TaggedFormat, Variant}; +use crate::utils::map_option_quote; use proc_macro2::TokenStream; use quote::quote; use syn::Fields; @@ -62,10 +63,16 @@ impl<'l> Container<'l> { let is_all_unit_enum = variants .iter() .all(|v| matches!(v.value.fields, Fields::Unit)); + let mut default_index = None; let variants_types = variants .iter() - .filter_map(|v| { + .enumerate() + .filter_map(|(i, v)| { + if v.is_default() { + default_index = Some(i); + } + if v.is_excluded() { None } else { @@ -81,6 +88,8 @@ impl<'l> Container<'l> { }) .collect::>(); + let default_index = map_option_quote("default_index", default_index); + if is_all_unit_enum { quote! { let mut values = vec![]; @@ -98,6 +107,7 @@ impl<'l> Container<'l> { name: Some(#config_name.into()), values, variants: Some(variants), + #default_index ..Default::default() }; @@ -112,6 +122,7 @@ impl<'l> Container<'l> { variants_types: vec![ #(Box::new(#variants_types)),* ], + #default_index ..Default::default() }; diff --git a/crates/macros/src/common/field.rs b/crates/macros/src/common/field.rs index 6465d323..3b01efa5 100644 --- a/crates/macros/src/common/field.rs +++ b/crates/macros/src/common/field.rs @@ -159,7 +159,7 @@ impl<'l> Field<'l> { } pub fn generate_schema_type(&self, casing_format: &str) -> TokenStream { - let name = map_option_quote("name", Some(self.get_name(Some(casing_format)))); + let name = self.get_name(Some(casing_format)); let hidden = map_bool_quote("hidden", self.is_skipped()); let nullable = map_bool_quote("nullable", self.is_optional()); let description = map_option_quote("description", extract_comment(&self.attrs)); @@ -199,8 +199,8 @@ impl<'l> Field<'l> { quote! { SchemaField { + name: #name.into(), type_of: #type_of, - #name #description #deprecated #env_var diff --git a/crates/macros/src/common/variant.rs b/crates/macros/src/common/variant.rs index 765c81c4..b37d2646 100644 --- a/crates/macros/src/common/variant.rs +++ b/crates/macros/src/common/variant.rs @@ -177,7 +177,7 @@ impl<'l> Variant<'l> { // return `SchemaType`. Be aware of this downstream! quote! { SchemaField { - name: Some(#name.into()), + name: #name.into(), type_of: #inner, ..Default::default() } diff --git a/crates/macros/src/config_enum/mod.rs b/crates/macros/src/config_enum/mod.rs index 7e316292..91c651d1 100644 --- a/crates/macros/src/config_enum/mod.rs +++ b/crates/macros/src/config_enum/mod.rs @@ -55,13 +55,18 @@ pub fn macro_impl(item: TokenStream) -> TokenStream { let mut from_stmts = vec![]; let mut schema_types = vec![]; let mut has_fallback = false; + let mut default_index = None; - for variant in variants { + for (index, variant) in variants.into_iter().enumerate() { unit_names.push(variant.get_unit_name()); display_stmts.push(variant.get_display_fmt()); from_stmts.push(variant.get_from_str()); schema_types.push(variant.get_schema_type()); + if variant.default { + default_index = Some(index); + } + if variant.args.fallback { if has_fallback { panic!("Only 1 fallback variant is supported.") @@ -165,6 +170,10 @@ pub fn macro_impl(item: TokenStream) -> TokenStream { #[cfg(feature = "schema")] { + use crate::utils::map_option_quote; + + let default_index = map_option_quote("default_index", default_index); + impls.push(quote! { #[automatically_derived] impl schematic::Schematic for #enum_name { @@ -183,10 +192,11 @@ pub fn macro_impl(item: TokenStream) -> TokenStream { } SchemaType::Enum(EnumType { - description: None, name: Some(#meta_name.into()), values, variants: Some(variants), + #default_index + ..EnumType::default() }) } } diff --git a/crates/macros/src/config_enum/variant.rs b/crates/macros/src/config_enum/variant.rs index 8efeafc2..0347cb09 100644 --- a/crates/macros/src/config_enum/variant.rs +++ b/crates/macros/src/config_enum/variant.rs @@ -1,6 +1,7 @@ use crate::common::FieldSerdeArgs; use crate::utils::{ - extract_comment, extract_common_attrs, extract_deprecated, format_case, map_option_quote, + extract_comment, extract_common_attrs, extract_deprecated, format_case, get_meta_path, + map_option_quote, }; use darling::FromAttributes; use proc_macro2::{Ident, TokenStream}; @@ -17,6 +18,7 @@ pub struct VariantArgs { pub struct Variant<'l> { pub args: VariantArgs, + pub default: bool, pub serde_args: FieldSerdeArgs, pub attrs: Vec<&'l Attribute>, pub name: &'l Ident, @@ -54,8 +56,13 @@ impl<'l> Variant<'l> { format_case(format, variant.ident.to_string().as_str(), true) }; + let attrs = extract_common_attrs(&variant.attrs); + Variant { - attrs: extract_common_attrs(&variant.attrs), + default: attrs + .iter() + .any(|v| get_meta_path(&v.meta).is_ident("default")), + attrs, name: &variant.ident, value, args, @@ -102,7 +109,7 @@ impl<'l> Variant<'l> { } pub fn get_schema_type(&self) -> TokenStream { - let name = map_option_quote("name", Some(self.name.to_string())); + let name = self.name.to_string(); let description = map_option_quote("description", extract_comment(&self.attrs)); let deprecated = map_option_quote("deprecated", extract_deprecated(&self.attrs)); @@ -120,8 +127,8 @@ impl<'l> Variant<'l> { quote! { SchemaField { + name: #name.into(), type_of: #type_of, - #name #description #deprecated ..Default::default() diff --git a/crates/macros/src/utils.rs b/crates/macros/src/utils.rs index 8983999e..5874c5be 100644 --- a/crates/macros/src/utils.rs +++ b/crates/macros/src/utils.rs @@ -45,7 +45,7 @@ pub fn get_meta_path(meta: &Meta) -> &Path { } pub fn extract_common_attrs(attrs: &[Attribute]) -> Vec<&Attribute> { - let preserve = ["allow", "deprecated", "doc", "warn"]; + let preserve = ["allow", "default", "deprecated", "doc", "warn"]; attrs .iter() diff --git a/crates/types/src/enums.rs b/crates/types/src/enums.rs index c641654b..58411f79 100644 --- a/crates/types/src/enums.rs +++ b/crates/types/src/enums.rs @@ -3,6 +3,7 @@ use crate::SchemaField; #[derive(Clone, Debug, Default)] pub struct EnumType { + pub default_index: Option, pub description: Option, pub name: Option, pub values: Vec, diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 72a5fe80..1bce942e 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -196,10 +196,33 @@ impl SchemaType { pub fn get_default(&self) -> Option<&LiteralValue> { match self { SchemaType::Boolean(BooleanType { default, .. }) => default.as_ref(), + SchemaType::Enum(EnumType { + default_index, + values, + .. + }) => { + if let Some(index) = default_index { + if let Some(value) = values.get(*index) { + return Some(value); + } + } + + None + } SchemaType::Float(FloatType { default, .. }) => default.as_ref(), SchemaType::Integer(IntegerType { default, .. }) => default.as_ref(), SchemaType::String(StringType { default, .. }) => default.as_ref(), - SchemaType::Union(UnionType { variants_types, .. }) => { + SchemaType::Union(UnionType { + default_index, + variants_types, + .. + }) => { + if let Some(index) = default_index { + if let Some(value) = variants_types.get(*index) { + return value.get_default(); + } + } + for variant in variants_types { if let Some(value) = variant.get_default() { return Some(value); @@ -231,6 +254,20 @@ impl SchemaType { } } + /// Return true if the schema is an explicit null. + pub fn is_null(&self) -> bool { + matches!(self, SchemaType::Null) + } + + /// Return true if the schema is nullable (a union with a null). + pub fn is_nullable(&self) -> bool { + if let SchemaType::Union(uni) = self { + return uni.is_nullable(); + } + + false + } + /// Set the `default` of the inner schema type. pub fn set_default(&mut self, default: LiteralValue) { match self { @@ -340,7 +377,7 @@ impl SchemaType { /// Represents a field within a schema struct, or a variant within a schema enum/union. #[derive(Clone, Debug, Default)] pub struct SchemaField { - pub name: Option, + pub name: String, pub description: Option, pub type_of: SchemaType, pub deprecated: Option, @@ -356,7 +393,7 @@ impl SchemaField { /// Create a new field with the provided name and type. pub fn new(name: &str, type_of: SchemaType) -> SchemaField { SchemaField { - name: Some(name.to_owned()), + name: name.to_owned(), type_of, ..SchemaField::default() } diff --git a/crates/types/src/unions.rs b/crates/types/src/unions.rs index 75565a17..0a90f2ee 100644 --- a/crates/types/src/unions.rs +++ b/crates/types/src/unions.rs @@ -9,6 +9,7 @@ pub enum UnionOperator { #[derive(Clone, Debug, Default)] pub struct UnionType { + pub default_index: Option, pub description: Option, pub name: Option, pub partial: bool, @@ -16,3 +17,9 @@ pub struct UnionType { pub variants: Option>, pub variants_types: Vec>, } + +impl UnionType { + pub fn is_nullable(&self) -> bool { + self.variants_types.iter().any(|v| v.is_null()) + } +}