Compare commits

...

6 Commits

Author SHA1 Message Date
Betterlol 5eeab6a062 [fix]: moon fmt.
MoonZod CI / build-and-test (ubuntu-latest) (push) Failing after 1m23s Details
MoonZod CI / build-and-test (macos-latest) (push) Has been cancelled Details
MoonZod CI / build-and-test (windows-latest) (push) Has been cancelled Details
2026-07-18 14:51:15 +08:00
Betterlol 09de42e8bb [Phase 45][feat]: add enum exclude() and extract() (Phase 45)
- Schema::exclude(values): returns new enum with specified values removed
- Schema::extract(values): returns new enum with only specified values kept
- Preserves metadata (name, description, brand, invalid_type_error, rules)
- Empty result produces never-matching enum
- 8 tests: exclude, extract, metadata preservation, empty edge cases,
  chained rules preserved
- 539 tests pass, 0 warnings
2026-07-18 14:48:59 +08:00
Betterlol 667911c47a [docs]: update moonbit_syntax_pitfalls.md 2026-07-18 14:44:03 +08:00
Betterlol 2a24a26dd5 [Phase 44][feat]: add PipeType for explicit two-stage validation
- New PipeType(Schema, TransformClosure, Schema) for chaining schemas
  with clear stage boundaries via Schema::pipe(output)
- parse_pipe: parse_inner(input) → bridge → parse_inner(output)
- append_rule penetrates PipeType to output (rules after pipe go to
  second stage)
- Exporters render as 'input → output' (prompt), transparent fallthrough
  to output (json_schema), output type (moonbit_struct), .pipe() code gen
- 7 tests: basic chain, stage rejection, transform bridge, chained
  rules after pipe, error_map on second stage
- 531 tests pass, 0 warnings
2026-07-18 14:43:23 +08:00
Betterlol 3d3fccdcba [Phase 43][fix]: review issues — DU rules passthrough, docstring, valid_keys allocation
- DiscriminatedUnionType now checks self.rules after dispatch (fixes
  silent ignore of .refine() and chained rules on DU schemas)
- docstring for recursive() shows working fn-based pattern
- valid_keys array moved inside None branch (no allocation on success)
2026-07-18 14:29:13 +08:00
Betterlol 5532fa61f5 [Phase 43][feat]: add recursive schema and discriminated union
- New SchemaType::LazyType(() -> Schema) for recursive/self-referencing schemas
  via recursive(fn) factory. LazyType resolved at parse time; post-parse rules
  on lazy wrapper checked against original input.
- New SchemaType::DiscriminatedUnionType(String, Map[String, Schema]) for
  O(1) branch dispatch via discriminator field, via discriminated_union()
  factory. Supports MissingRequired/InvalidValue/InvalidType errors.
- All exporters handle both new types: LazyType resolves closure and recurses;
  DiscriminatedUnionType renders as union of options.
- 10 new tests (5 recursive + 5 discriminated_union).
- 523 tests pass, 0 warnings.
2026-07-18 14:24:46 +08:00
18 changed files with 770 additions and 0 deletions

View File

@ -0,0 +1,112 @@
///|
/// Create a schema that dispatches to one of several options based on a
/// discriminator field value. Unlike `union()`, this directly selects the
/// matching schema in O(1) instead of trying each branch in order.
///
/// # Example
/// ```mbt nocheck
/// let pet = @moon_zod.discriminated_union("type", {
/// "dog": @moon_zod.object({ "bark": @moon_zod.boolean() }),
/// "cat": @moon_zod.object({ "meow": @moon_zod.boolean() }),
/// })
/// ```
pub fn discriminated_union(
discriminator : String,
options : Map[String, Schema],
) -> Schema {
{
schema_type: DiscriminatedUnionType(discriminator, options),
rules: [],
description: "",
required_error: "",
invalid_type_error: "",
name: "",
brand: "",
}
}
///|
pub fn Schema::parse_discriminated_union(
self : Schema,
discriminator : String,
options : Map[String, Schema],
json : Json,
path_stack : Array[String],
) -> RawSchemaResult {
let dispatch_result = match json {
Object(map) => {
path_stack.push(discriminator)
let disc_path = format_path(path_stack)
let _ = path_stack.pop()
match map.get(discriminator) {
None =>
Err([
RawIssue::{
code: IssueCode::MissingRequired(discriminator),
path: disc_path,
message: "Required",
input: json,
},
])
Some(String(disc_value)) =>
match options.get(disc_value) {
Some(schema) => parse_inner(schema, json, path_stack)
None => {
let valid_keys : Array[Json] = []
for k in options.keys() {
valid_keys.push(Json::string(k))
}
Err([
RawIssue::{
code: IssueCode::InvalidValue(valid_keys),
path: disc_path,
message: "Invalid discriminator value",
input: json,
},
])
}
}
Some(_) =>
Err([
RawIssue::{
code: IssueCode::InvalidType("string"),
path: disc_path,
message: "Discriminator field must be a string",
input: json,
},
])
}
}
_ => {
let path = format_path(path_stack)
Err([
RawIssue::{
code: IssueCode::InvalidType("object"),
path,
message: "Expected object for discriminated union",
input: json,
},
])
}
}
// Check rules on the DU schema itself (for .refine() and chained rules)
match dispatch_result {
Ok(v) => {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, json, self.rules)
if raw.is_empty() {
Ok(v)
} else {
Err(raw)
}
}
Err(dispatch_raw) => {
let raw : Array[RawIssue] = []
for r in dispatch_raw {
raw.push(r)
}
collect_raw_errors(raw, path_stack, json, self.rules)
Err(raw)
}
}
}

View File

@ -16,6 +16,60 @@ pub fn enum_values(
}
}
///|
/// Exclude specific values from an enum schema.
/// Returns a new schema with the remaining values.
/// Aborts if called on a non-enum schema.
///
/// # Example
/// ```mbt nocheck
/// let colors = enum_values(["red", "green", "blue"])
///
/// let no_green = colors.exclude(["green"])
/// // now accepts only "red" and "blue"
/// ```
pub fn Schema::exclude(self : Schema, values : Array[String]) -> Schema {
match self.schema_type {
EnumType(existing) => {
let filtered : Array[String] = []
for v in existing {
if !value_in_array(v, values) {
filtered.push(v)
}
}
{ ..self, schema_type: EnumType(filtered) }
}
_ => abort("exclude() is only valid for enum schemas")
}
}
///|
/// Extract specific values from an enum schema.
/// Returns a new schema that only accepts the specified values.
/// Aborts if called on a non-enum schema.
///
/// # Example
/// ```mbt nocheck
/// let colors = enum_values(["red", "green", "blue"])
///
/// let warm = colors.extract(["red", "orange"]) // "orange" ignored, only "red" kept
/// // now accepts only "red"
/// ```
pub fn Schema::extract(self : Schema, values : Array[String]) -> Schema {
match self.schema_type {
EnumType(existing) => {
let filtered : Array[String] = []
for v in existing {
if value_in_array(v, values) {
filtered.push(v)
}
}
{ ..self, schema_type: EnumType(filtered) }
}
_ => abort("extract() is only valid for enum schemas")
}
}
///|
pub fn Schema::parse_enum(
_self : Schema,

View File

@ -109,5 +109,8 @@ pub fn type_origin(t : SchemaType) -> String {
PreprocessType(_, _) => "preprocess"
OptionalType(_) => "optional"
DefaultType(_, _) => "default"
LazyType(_) => "lazy"
DiscriminatedUnionType(_, _) => "discriminated_union"
PipeType(_, _, _) => "pipe"
}
}

48
core/lazy.mbt Normal file
View File

@ -0,0 +1,48 @@
///|
/// Create a schema that defers resolution to parse time.
/// Required for recursive/self-referencing schemas (trees, linked lists, etc.).
///
/// # Example
/// ```mbt nocheck
/// fn tree_schema() -> @moon_zod.Schema {
/// @moon_zod.recursive(fn() {
/// @moon_zod.object({
/// "value": @moon_zod.number(),
/// "children": @moon_zod.array(@moon_zod.recursive(tree_schema)).optional(),
/// })
/// })
/// }
/// ```
pub fn recursive(f : () -> Schema) -> Schema {
{
schema_type: LazyType(f),
rules: [],
description: "",
required_error: "",
invalid_type_error: "",
name: "",
brand: "",
}
}
///|
pub fn Schema::parse_lazy(
self : Schema,
f : () -> Schema,
json : Json,
path_stack : Array[String],
) -> RawSchemaResult {
let resolved = f()
match parse_inner(resolved, json, path_stack) {
Err(e) => Err(e)
Ok(v) => {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, json, self.rules)
if raw.is_empty() {
Ok(v)
} else {
Err(raw)
}
}
}
}

59
core/pipe.mbt Normal file
View File

@ -0,0 +1,59 @@
///|
/// Chain two schemas: validate the input against `self`, then validate the
/// result against `output`. Errors from each stage are reported independently.
///
/// Unlike `.transform()`, `.pipe()` creates a clear two-stage pipeline where
/// the output schema can have its own rules — errors are attributed to the
/// correct stage.
///
/// # Example
/// ```mbt nocheck
/// let s = string()
/// .transform(fn(s) { Ok(Json::string(s.length().to_string())) })
/// .pipe(number().min(5))
/// // string check → transform → number check(.min(5))
/// ```
pub fn Schema::pipe(self : Schema, output : Schema) -> Schema {
{
schema_type: PipeType(
self,
TransformClosure::{ f: fn(v) { Ok(v) } },
output,
),
rules: [],
description: self.description,
required_error: self.required_error,
invalid_type_error: self.invalid_type_error,
name: self.name,
brand: self.brand,
}
}
///|
pub fn Schema::parse_pipe(
_self : Schema,
input_schema : Schema,
bridge : TransformClosure,
output_schema : Schema,
json : Json,
path_stack : Array[String],
) -> RawSchemaResult {
match parse_inner(input_schema, json, path_stack) {
Err(e) => Err(e)
Ok(parsed) =>
match (bridge.f)(parsed) {
Err(msg) => {
let path = format_path(path_stack)
Err([
RawIssue::{
code: IssueCode::Custom,
path,
message: msg,
input: json,
},
])
}
Ok(bridged) => parse_inner(output_schema, bridged, path_stack)
}
}
}

View File

@ -26,6 +26,9 @@ pub(all) enum SchemaType {
PreprocessType(TransformClosure, Schema)
TransformType(Schema, TransformClosure)
LiteralType(Json)
LazyType(() -> Schema)
DiscriminatedUnionType(String, Map[String, Schema])
PipeType(Schema, TransformClosure, Schema)
} derive(Debug)
///|
@ -140,6 +143,18 @@ pub fn Schema::message(self : Schema, text : String) -> Schema {
brand: self.brand,
}
}
PipeType(input, bridge, output) => {
let new_output = output.message(text)
{
schema_type: PipeType(input, bridge, new_output),
rules: [],
description: self.description,
required_error: self.required_error,
invalid_type_error: self.invalid_type_error,
name: self.name,
brand: self.brand,
}
}
_ => {
let n = self.rules.length()
if n == 0 {
@ -166,6 +181,8 @@ pub fn inner_type(t : SchemaType) -> SchemaType {
DefaultType(inner, _) => inner.schema_type
PreprocessType(_, inner) => inner.schema_type
TransformType(inner, _) => inner.schema_type
LazyType(_) | DiscriminatedUnionType(_, _) => t
PipeType(_, _, output) => inner_type(output.schema_type)
LiteralType(_) => t
other => other
}
@ -222,6 +239,20 @@ pub fn append_rule_with_annotation(
brand: schema.brand,
}
}
PipeType(input_schema, bridge, output_schema) => {
let new_output = append_rule_with_annotation(
output_schema, check, message, code, annotation,
)
{
schema_type: PipeType(input_schema, bridge, new_output),
rules: [],
description: schema.description,
required_error: schema.required_error,
invalid_type_error: schema.invalid_type_error,
name: schema.name,
brand: schema.brand,
}
}
_ =>
{ ..schema, rules: schema.rules + [{ check, message, code, annotation }] }
}
@ -318,6 +349,11 @@ fn parse_inner(
TransformType(inner, closure) =>
schema.parse_transform(inner, closure, json, path_stack)
LiteralType(expected) => schema.parse_literal(expected, json, path_stack)
LazyType(f) => schema.parse_lazy(f, json, path_stack)
DiscriminatedUnionType(disc, options) =>
schema.parse_discriminated_union(disc, options, json, path_stack)
PipeType(input_schema, bridge, output_schema) =>
schema.parse_pipe(input_schema, bridge, output_schema, json, path_stack)
_ => {
let valid = match (schema.schema_type, json) {
(StringType, String(_)) => true

View File

@ -13,6 +13,7 @@ pub fn unwrap_schema(schema : Schema) -> Schema {
DefaultType(inner, _) => unwrap_schema(inner)
PreprocessType(_, inner) => unwrap_schema(inner)
TransformType(inner, _) => unwrap_schema(inner)
PipeType(_, _, output) => unwrap_schema(output)
_ => schema
}
}
@ -95,6 +96,15 @@ pub fn collect_named_schemas_impl(
collect_named_schemas_impl(inner, visited, result)
TransformType(inner, _) =>
collect_named_schemas_impl(inner, visited, result)
LazyType(f) => collect_named_schemas_impl(f(), visited, result)
DiscriminatedUnionType(_, options) =>
for _key, option in options {
collect_named_schemas_impl(option, visited, result)
}
PipeType(input, _, output) => {
collect_named_schemas_impl(input, visited, result)
collect_named_schemas_impl(output, visited, result)
}
UnionType(schemas) =>
for s in schemas {
collect_named_schemas_impl(s, visited, result)
@ -243,6 +253,16 @@ pub fn find_schema_dependencies_impl(
find_schema_dependencies_impl(inner, schema_map, deps, visited_names)
TransformType(inner, _) =>
find_schema_dependencies_impl(inner, schema_map, deps, visited_names)
LazyType(f) =>
find_schema_dependencies_impl(f(), schema_map, deps, visited_names)
DiscriminatedUnionType(_, options) =>
for _key, option in options {
find_schema_dependencies_impl(option, schema_map, deps, visited_names)
}
PipeType(input, _, output) => {
find_schema_dependencies_impl(input, schema_map, deps, visited_names)
find_schema_dependencies_impl(output, schema_map, deps, visited_names)
}
UnionType(schemas) =>
for s in schemas {
if !s.name.is_empty() && name_exists_in_map(s.name) {

View File

@ -68,6 +68,9 @@ pub fn[R : JsonSchemaRenderer] render_json_type(
TransformType(inner, closure) =>
renderer.render_transform(inner, closure, schema)
LiteralType(value) => renderer.render_literal(value, schema)
LazyType(f) => render_json_type(renderer, f())
DiscriminatedUnionType(_, _) => Json::null()
PipeType(_, _, output) => render_json_type(renderer, output)
}
}

View File

@ -51,6 +51,15 @@ fn collect_type_defs(
for item in schemas {
collect_type_defs(item, type_hint, defs)
}
LazyType(f) => collect_type_defs(f(), type_hint, defs)
DiscriminatedUnionType(_, options) =>
for _key, option in options {
collect_type_defs(option, type_hint, defs)
}
PipeType(input, _, output) => {
collect_type_defs(input, type_hint, defs)
collect_type_defs(output, type_hint, defs)
}
_ => ()
}
}
@ -70,6 +79,15 @@ fn collect_field_defs(
for item in schemas {
collect_field_defs(item, type_hint, defs)
}
LazyType(f) => collect_field_defs(f(), type_hint, defs)
DiscriminatedUnionType(_, options) =>
for _key, option in options {
collect_field_defs(option, type_hint, defs)
}
PipeType(input, _, output) => {
collect_field_defs(input, type_hint, defs)
collect_field_defs(output, type_hint, defs)
}
ObjectType(_, _) | EnumType(_) => collect_type_defs(schema, type_hint, defs)
_ => ()
}
@ -239,6 +257,15 @@ fn field_to_moonbit_type(schema : @core.Schema, type_hint : String) -> String {
IntersectionType(schemas) =>
intersection_to_moonbit_type(schemas, type_hint)
LiteralType(value) => literal_to_moonbit_type(value)
LazyType(f) => field_to_moonbit_type(f(), type_hint)
DiscriminatedUnionType(_, options) => {
let schemas : Array[@core.Schema] = []
for _key, option in options {
schemas.push(option)
}
union_to_moonbit_type(schemas, type_hint)
}
PipeType(_, _, output) => field_to_moonbit_type(output, type_hint)
}
}

View File

@ -105,6 +105,18 @@ pub fn[R : StringRenderer] render_type(
TransformType(inner, closure) =>
renderer.render_transform(inner, closure, schema, indent)
LiteralType(value) => renderer.render_literal(value, schema, indent)
LazyType(f) => render_type(renderer, f(), indent)
DiscriminatedUnionType(_, options) => {
let schemas : Array[@core.Schema] = []
for _key, option in options {
schemas.push(option)
}
renderer.render_union(schemas, schema, indent)
}
PipeType(input, _, output) =>
render_type(renderer, input, indent) +
" → " +
render_type(renderer, output, indent)
}
}

View File

@ -218,6 +218,33 @@ fn schema_type_to_code(
TransformType(inner, _) =>
schema_to_moon_zod_code_inline(inner, Some(defined_names)) +
".transform(fn(x) { Ok(x) })" // TODO: Handle transform function code export
LazyType(f) => schema_to_moon_zod_code_inline(f(), Some(defined_names))
DiscriminatedUnionType(disc, options) => {
let parts : Array[String] = []
for key, option in options {
let opt_code = schema_to_moon_zod_code_inline(
option,
Some(defined_names),
)
parts.push("\"" + @core.escape_mbt_string(key) + "\": " + opt_code)
}
"@moon_zod.discriminated_union(\"" +
@core.escape_mbt_string(disc) +
"\", {" +
join_with(parts, ", ") +
"})"
}
PipeType(input, _, output) => {
let input_code = schema_to_moon_zod_code_inline(
input,
Some(defined_names),
)
let output_code = schema_to_moon_zod_code_inline(
output,
Some(defined_names),
)
input_code + ".pipe(" + output_code + ")"
}
}
}

View File

@ -68,6 +68,37 @@ MoonBit 没有 `cond ? a : b`,用 `if-else` 表达式替代:
let label = if n > 0 { "positive" } else { "non-positive" }
```
### `lazy` 是保留关键字
MoonBit 已预留 `lazy` 关键字,不能用作函数名或变量名:
```mbt
// ❌ 警告lazy is reserved for possible future use
pub fn lazy(f : () -> Schema) -> Schema { ... }
// ✅ 改用其他名称
pub fn recursive(f : () -> Schema) -> Schema { ... }
```
### `let rec` 只支持函数
`let rec` 只能用于递归**函数**定义,不能用于递归**值**
```mbt
// ❌ 编译错误The value identifier tree is unbound
let rec tree = object({ "children": array(recursive(fn() { tree })).optional() })
// ✅ 用函数模式包装
fn tree_schema() -> Schema {
recursive(fn() {
object({
"value": number(),
"children": array(recursive(tree_schema)).optional(),
})
})
}
```
### `[]` 创建空数组需要类型注解
```mbt
@ -109,6 +140,22 @@ let json = @json.parse(raw) catch {
}
```
### Match 臂内联时需 `;` 分隔分支
当 match 写在一行时,分支之间需要 `;` 分隔:
```mbt
// ❌ 解析错误unexpected token `_`
match json { Number(v, ..) => v > 0.0 _ => false }
// ✅ 正确:换行或者用 ; 分隔
match json {
Number(v, ..) => v > 0.0
_ => false
}
match json { Number(v, ..) => v > 0.0; _ => false }
```
### Match 分支返回值必须一致
所有 match arm 必须返回相同类型:
@ -180,6 +227,21 @@ let top = stack.pop() // Option[String]
let _ = stack.pop() // 忽略 None
```
### Map 方法返回 Iter不是 Array
`Map.keys()`、`Map.values()` 返回 `Iter` 类型,不能直接链式调用 `Array` 方法:
```mbt
// ❌ 类型错误Expr Type Mismatch, has type Iter[T], wanted Array[T]
let keys_json : Array[Json] = options.keys().map(fn(k) { Json::string(k) })
// ✅ 手动收集
let keys_json : Array[Json] = []
for k in options.keys() {
keys_json.push(Json::string(k))
}
```
### 有载荷的 enum 变体用 `::{}` 构造
```mbt
@ -508,6 +570,22 @@ pub fn Empty::Empty() -> Empty {
}
```
### Match 臂中 `{ }` 块可能引起解析歧义
在返回 `Json` 等需要推断类型的 match 臂中使用 `{ let ...; expr }` 块,可能触发编译器关于表达式的歧义警告。如有歧义,可抽提为独立函数或简化 match 臂:
```mbt
// ⚠️ 可能触发 "value cannot be implicitly ignored" 级联错误
DiscriminatedUnionType(_, options) => {
let schemas : Array[@core.Schema] = []
for _key, option in options { schemas.push(option) }
renderer.render_union(schemas, schema)
}
// ✅ 简化为单一表达式
DiscriminatedUnionType(_, _) => Json::null()
```
### 字符串中 `\{` 永远是插值
MoonBit 字符串插值用 `\{expr}`**无法在插值字符串中包含字面量 `{`**

View File

@ -32,6 +32,8 @@ pub using @core {
intersection,
preprocess,
literal,
recursive,
discriminated_union,
unwrap_schema,
peel_optional,
indent_str,

View File

@ -40,6 +40,8 @@ pub using @core {
intersection,
preprocess,
literal,
recursive,
discriminated_union,
unwrap_schema,
peel_optional,
indent_str,

View File

@ -0,0 +1,71 @@
///|
test "discriminated union dispatches correctly" {
let s = discriminated_union("type", {
"dog": object({ "bark": boolean() }),
"cat": object({ "meow": boolean() }),
})
let dog_input = parse_json("{\"type\": \"dog\", \"bark\": true}")
guard s.parse(dog_input) is Ok(_) else { fail("expected Ok for dog") }
let cat_input = parse_json("{\"type\": \"cat\", \"meow\": false}")
guard s.parse(cat_input) is Ok(_) else { fail("expected Ok for cat") }
}
///|
test "discriminated union rejects unknown discriminator" {
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
let input = parse_json("{\"type\": \"fish\"}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
match errors[0].code {
IssueCode::InvalidValue(_) => ()
_ => fail("expected InvalidValue")
}
}
///|
test "discriminated union missing discriminator field" {
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
let input = parse_json("{\"name\": \"fido\"}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
match errors[0].code {
IssueCode::MissingRequired("type") => ()
_ => fail("expected MissingRequired")
}
}
///|
test "discriminated union rejects non-object input" {
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
guard s.parse(Json::string("hello")) is Err(_) else { fail("expected Err") }
}
///|
test "discriminated union validates against selected schema" {
let s = discriminated_union("type", {
"dog": object({ "bark": boolean() }).strict(),
})
// dog schema uses strict mode: "meow" is not a valid field
let input = parse_json("{\"type\": \"dog\", \"bark\": true, \"meow\": false}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
match errors[0].code {
IssueCode::UnrecognizedKeys(_) => ()
_ => fail("expected UnrecognizedKeys")
}
}
///|
test "discriminated union refine rule is checked" {
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) }).refine(
fn(json) {
match json {
Object(map) => map.contains("active")
_ => false
}
},
"active field required",
)
let input = parse_json("{\"type\": \"dog\", \"bark\": true}")
match s.parse(input) {
Err(errors) => @debug.assert_eq(errors[0].message, "active field required")
_ => fail("expected Err")
}
}

View File

@ -0,0 +1,79 @@
///|
test "enum exclude removes values" {
let s = enum_values(["red", "green", "blue"]).exclude(["green"])
guard s.parse(Json::string("red")) is Ok(_) else {
fail("expected Ok for red")
}
guard s.parse(Json::string("blue")) is Ok(_) else {
fail("expected Ok for blue")
}
guard s.parse(Json::string("green")) is Err(_) else {
fail("expected Err for green")
}
}
///|
test "enum exclude multiple values" {
let s = enum_values(["a", "b", "c", "d"]).exclude(["a", "c"])
guard s.parse(Json::string("b")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("d")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
guard s.parse(Json::string("c")) is Err(_) else { fail("expected Err") }
}
///|
test "enum exclude preserves metadata" {
let s = enum_values(["x", "y"], invalid_type_error="not enum")
.name("MyEnum")
.exclude(["y"])
@debug.assert_eq(s.name, "MyEnum")
@debug.assert_eq(s.invalid_type_error, "not enum")
guard s.parse(Json::string("x")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("y")) is Err(_) else { fail("expected Err") }
}
///|
test "enum extract keeps only specified values" {
let s = enum_values(["red", "green", "blue"]).extract(["red", "blue"])
guard s.parse(Json::string("red")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("blue")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("green")) is Err(_) else {
fail("expected Err for green")
}
}
///|
test "enum extract ignores values not in original" {
let s = enum_values(["a", "b"]).extract(["a", "c"])
guard s.parse(Json::string("a")) is Ok(_) else { fail("expected Ok") }
guard s.parse(Json::string("b")) is Err(_) else { fail("expected Err") }
}
///|
test "enum exclude all values produces empty enum" {
let s = enum_values(["a", "b"]).exclude(["a", "b"])
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
guard s.parse(Json::string("b")) is Err(_) else { fail("expected Err") }
}
///|
test "enum extract empty list produces empty enum" {
let s = enum_values(["a", "b"]).extract([])
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
}
///|
test "enum exclude preserves rules" {
let s = enum_values(["a", "b"])
.refine(
fn(json) {
match json {
String(v) => v.length() > 0
_ => false
}
},
"must not be empty",
)
.exclude(["b"])
guard s.parse(Json::string("a")) is Ok(_) else { fail("expected Ok") }
}

89
tests/test_pipe.mbt Normal file
View File

@ -0,0 +1,89 @@
///|
test "pipe chains two schemas" {
let s = string().pipe(string().min(3).max(10))
guard s.parse(Json::string("hello")) is Ok(_) else { fail("expected Ok") }
}
///|
test "pipe rejects first stage" {
let s = number().pipe(string())
guard s.parse(Json::string("hello")) is Err(_) else { fail("expected Err") }
}
///|
test "pipe rejects second stage" {
let s = string().pipe(string().min(3))
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors.length(), 1)
match errors[0].code {
IssueCode::TooSmall(_, _, _) => ()
_ => fail("expected TooSmall")
}
}
///|
test "pipe with transform bridges types" {
let s = string()
.transform(fn(json) {
match json {
String(s) => Ok(Json::number(s.length().to_double()))
_ => Err("expected string")
}
})
.pipe(number().min(3).max(10).int())
// "hello".length() = 5, which passes min(3) and max(10)
guard s.parse(Json::string("hello")) is Ok(_) else { fail("expected Ok") }
// "hi".length() = 2, fails min(3)
guard s.parse(Json::string("hi")) is Err(_) else { fail("expected Err") }
}
///|
test "pipe reports first stage error before second stage" {
// If first stage fails, second stage is never tried
let s = number()
.refine(
fn(json) {
match json {
Number(v, ..) => v > 0.0
_ => false
}
},
"must be positive",
)
.pipe(string())
guard s.parse(Json::number(-1.0)) is Err(_) else { fail("expected Err") }
}
///|
test "pipe with chained rules after pipe" {
let s = string()
.pipe(number())
.refine(
fn(json) {
match json {
Number(v, ..) => v > 0.0
_ => false
}
},
"must be positive",
)
guard s.parse(Json::string("42")) is Err(_) else { fail("expected Err") }
}
///|
test "pipe error_map works for second stage" {
let s = string().pipe(string().min(3))
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::TooSmall(_, _, _) => Some("太短了")
_ => None
}
}),
}
match s.safe_parse(Json::string("ab"), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "太短了")
_ => fail("expected Err")
}
}

48
tests/test_recursive.mbt Normal file
View File

@ -0,0 +1,48 @@
///|
fn tree_schema() -> Schema {
recursive(fn() {
object({
"value": number(),
"children": array(recursive(tree_schema)).optional(),
})
})
}
///|
test "recursive tree schema parses valid input" {
let s = tree_schema()
let input = parse_json("{\"value\": 1, \"children\": [{\"value\": 2}]}")
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
}
///|
test "recursive tree schema rejects invalid input" {
let s = tree_schema()
let input = parse_json("{\"value\": \"not-a-number\"}")
guard s.parse(input) is Err(_) else { fail("expected Err") }
}
///|
test "recursive schema with named root" {
let s = tree_schema().name("Tree")
let input = parse_json("{\"value\": 1}")
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
}
///|
test "recursive schema self-reference parses deeply nested" {
let s = tree_schema()
let input = parse_json(
"{\"value\": 1, \"children\": [{\"value\": 2, \"children\": [{\"value\": 3}]}]}",
)
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
}
///|
test "recursive schema rejects invalid nested value" {
let s = tree_schema()
let input = parse_json(
"{\"value\": 1, \"children\": [{\"value\": 2, \"children\": [{\"value\": \"bad\"}]}]}",
)
guard s.parse(input) is Err(_) else { fail("expected Err") }
}