Compare commits

...

5 Commits

Author SHA1 Message Date
Betterlol f6ad1f4e60 [fix]: moon fmt.
MoonZod CI / build-and-test (macos-latest) (push) Waiting to run Details
MoonZod CI / build-and-test (windows-latest) (push) Waiting to run Details
MoonZod CI / build-and-test (ubuntu-latest) (push) Failing after 1m0s Details
2026-07-18 14:14:10 +08:00
Betterlol ab123991ee [Phase 42][fix]: constraint_extractor uses IssueCode; collect_raw_errors takes path_stack
- constraint_extractor now extracts min/max/format/multipleOf/is_int from
  Rule.code (structured IssueCode) instead of only parsing annotation JSON.
  Annotation JSON kept as fallback for Custom rules.
- collect_raw_errors accepts Array[String] path_stack and formats internally,
  removing redundant 'let path = format_path(path_stack)' at all 7 call sites.
- Update test_prompt expectation: nonempty() now correctly shows [min: 1]
  since Its TooSmall code is properly extracted.
2026-07-18 12:02:50 +08:00
Betterlol f96091e310 [Phase 42][fix]: address review feedback
- Fix tuple.mbt: use TooBig/TooSmall for length mismatch (not InvalidUnion)
- Unify InvalidType origin: use type_origin() in array/object/tuple
- Restore UUID/ULID comments accidentally removed in refactor
2026-07-18 11:56:04 +08:00
Betterlol 5ba5342d39 [Phase 42][feat]: add IssueCode + ErrorMap + ParseParams error system
- Add core/errors.mbt with IssueCode enum (12 variants), ErrorMap type,
  ParseParams, RawIssue, finalize_issue/finalize_issues pipeline
- Add code: IssueCode field to ValidationError and Rule
- Add Schema::safe_parse(self, json, ParseParams) public API
- Messages resolved at error source (no Schema ref in RawIssue)
- Path stored as pre-formatted String (no double allocation)
- All rule methods pass precise IssueCode (InvalidFormat for validators,
  TooBig/TooSmall for bounds, NotMultipleOf, InvalidValue, etc.)
- 34 new tests for issue codes and error map behavior
- All 513 tests pass, 0 warnings
2026-07-18 11:41:40 +08:00
Betterlol 9ba690fc0e [docs]: update test counts in readme.
MoonZod CI / build-and-test (macos-latest) (push) Waiting to run Details
MoonZod CI / build-and-test (windows-latest) (push) Waiting to run Details
MoonZod CI / build-and-test (ubuntu-latest) (push) Failing after 59s Details
2026-07-17 21:18:31 +08:00
27 changed files with 832 additions and 148 deletions

View File

@ -141,7 +141,7 @@ moon_zod/
├── importers/ # Schema import tools
│ └── from_json_schema.mbt # json_schema_to_moon_zod() — reverse JSON Schema → moon_zod code generation
├── tests/ # Test suite (466 tests)
├── tests/ # Test suite (479 tests)
│ ├── test_string.mbt # string() validator tests (trim, to_lower, to_upper, nonempty)
│ ├── test_number.mbt # number() validator tests
│ ├── test_boolean_null.mbt # boolean/null tests
@ -189,7 +189,7 @@ moon_zod/
```bash
# Testing & Building
moon test # Run all tests (466 total, 0 warnings)
moon test # Run all tests (479 total, 0 warnings)
moon build # Build the library
moon check # Type check (0 errors, 0 warnings)
moon info && moon fmt # Update interface + format

View File

@ -141,7 +141,7 @@ moon_zod/
├── importers/ # Schema 导入工具
│ └── from_json_schema.mbt # json_schema_to_moon_zod() —— 反向 JSON Schema → moon_zod 代码生成
├── tests/ # 测试套件466 个测试)
├── tests/ # 测试套件479 个测试)
│ ├── test_string.mbt # string() 校验器测试trim、to_lower、to_upper、nonempty
│ ├── test_number.mbt # number() 校验器测试
│ ├── test_boolean_null.mbt # boolean/null 测试
@ -189,7 +189,7 @@ moon_zod/
```bash
# 测试与构建
moon test # 运行所有测试(共 4660 警告)
moon test # 运行所有测试(共 4790 警告)
moon build # 构建库
moon check # 类型检查0 错误0 警告)
moon info && moon fmt # 更新接口 + 格式化

View File

@ -24,34 +24,42 @@ pub fn Schema::parse_array(
element_schema : Schema,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match json {
Array(elements) => {
let errors : Array[ValidationError] = []
let raw : Array[RawIssue] = []
let mut i = 0
for element in elements {
path_stack.push("[\{i}]")
let result = parse_inner(element_schema, element, path_stack)
match result {
Err(item_errors) =>
for e in item_errors {
errors.push(e)
Err(item_raw) =>
for e in item_raw {
raw.push(e)
}
_ => ()
}
let _ = path_stack.pop()
i = i + 1
}
collect_errors(errors, path_stack, json, self.rules)
if errors.is_empty() {
collect_raw_errors(raw, path_stack, json, self.rules)
if raw.is_empty() {
Ok(json)
} else {
Err(errors)
Err(raw)
}
}
_ => {
let path = format_path(path_stack)
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
let message = type_msg(self)
Err([
RawIssue::{
code: IssueCode::InvalidType(type_origin(self.schema_type)),
path,
message,
input: json,
},
])
}
}
}

View File

@ -43,11 +43,31 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
let mut multiple_of_val = 0.0
let custom_messages_val : Array[String] = []
// First pass: extract from JSON annotations
// First pass: extract from IssueCode (structured, fast path)
for rule in rules {
match rule.code {
TooSmall(_, val, _) => min_value = val
TooBig(_, val, _) => max_value = val
InvalidFormat(fmt) =>
if fmt == "integer" {
is_int_val = true
} else {
format_val = fmt
}
NotMultipleOf(div) => multiple_of_val = div
_ => ()
}
match rule.code {
TooSmall("number", 0.0, false) => is_positive_val = true
TooBig("number", 0.0, false) => is_negative_val = true
_ => ()
}
}
// Second pass: extract from JSON annotations (fallback for Custom rules)
for rule in rules {
match rule.annotation {
Object(map) => {
// Length/Value constraints
if map.contains("minLength") {
match map.get("minLength") {
Some(Number(v, ..)) => min_value = v
@ -84,8 +104,6 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
_ => ()
}
}
// Format and pattern
if map.contains("format") {
match map.get("format") {
Some(String(s)) => format_val = s
@ -98,16 +116,12 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
_ => ()
}
}
// Type-specific flags
if map.contains("type") {
match map.get("type") {
Some(String(s)) => if s == "integer" { is_int_val = true }
_ => ()
}
}
// Numeric constraints
if map.contains("exclusiveMinimum") {
match map.get("exclusiveMinimum") {
Some(Number(v, ..)) => if v == 0.0 { is_positive_val = true }
@ -131,11 +145,10 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
}
}
// Second pass: collect custom error messages
// Third pass: collect custom error messages
for rule in rules {
match rule.annotation {
Null =>
// Skip certain standard messages
if rule.message != "String must not be empty" {
custom_messages_val.push(rule.message)
}

View File

@ -21,7 +21,7 @@ pub fn Schema::parse_default(
default_val : Json,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match json {
Null => Ok(default_val)
_ => parse_inner(inner, json, path_stack)

View File

@ -22,7 +22,7 @@ pub fn Schema::parse_enum(
values : Array[String],
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
let path = format_path(path_stack)
match json {
String(s) =>
@ -30,15 +30,21 @@ pub fn Schema::parse_enum(
Ok(json)
} else {
Err([
ValidationError::{ path, message: "Invalid enum value", got: json },
RawIssue::{
code: IssueCode::InvalidValue(values.map(fn(v) { Json::string(v) })),
path,
message: "Invalid enum value",
input: json,
},
])
}
_ =>
Err([
ValidationError::{
RawIssue::{
code: IssueCode::InvalidType("string"),
path,
message: "Expected string for enum",
got: json,
input: json,
},
])
}

113
core/errors.mbt Normal file
View File

@ -0,0 +1,113 @@
///|
/// Machine-readable issue code for structured error classification.
pub(all) enum IssueCode {
InvalidType(String)
TooBig(String, Double, Bool)
TooSmall(String, Double, Bool)
InvalidFormat(String)
NotMultipleOf(Double)
UnrecognizedKeys(Array[String])
InvalidUnion(Array[String])
MissingRequired(String)
InvalidKey(String)
InvalidElement(String, Int)
InvalidValue(Array[Json])
Custom
} derive(Debug, Eq)
///|
/// Error map function: contextual override for error messages.
/// Returns `Some(msg)` to override, `None` to fall through to the pre-resolved message.
pub type ErrorMap = (IssueCode, String, Json) -> String?
///|
/// Parameters for `Schema::safe_parse`.
pub(all) struct ParseParams {
path : String
error_map : ErrorMap?
}
///|
pub fn ParseParams::default() -> ParseParams {
ParseParams::{ path: "", error_map: None }
}
///|
/// A raw validation issue with the message pre-resolved at the generation site.
/// No Schema reference needed — messages are resolved immediately from
/// rule.message / schema.invalid_type_error / schema.required_error / hardcoded default.
pub(all) struct RawIssue {
code : IssueCode
path : String
message : String
input : Json
}
///|
/// Resolve an error message using the error map priority chain.
/// Priority: error_map (contextual) → pre-resolved message.
pub fn finalize_issue(raw : RawIssue, params : ParseParams) -> ValidationError {
let message = match params.error_map {
Some(map) =>
match map(raw.code, raw.path, raw.input) {
Some(m) => if m.is_empty() { raw.message } else { m }
None => raw.message
}
None => raw.message
}
ValidationError::{ code: raw.code, path: raw.path, message, got: raw.input }
}
///|
/// Convert raw issues to validation errors using finalize_issue.
pub fn finalize_issues(
raw_issues : Array[RawIssue],
params : ParseParams,
) -> Array[ValidationError] {
raw_issues.map(fn(raw) { finalize_issue(raw, params) })
}
///|
/// Collect raw issues from rule failures.
pub fn collect_raw_errors(
out : Array[RawIssue],
path_stack : Array[String],
json : Json,
rules : Array[Rule],
) -> Unit {
let path = format_path(path_stack)
for rule in rules {
if !(rule.check)(json) {
out.push(RawIssue::{
code: rule.code,
path,
message: rule.message,
input: json,
})
}
}
}
///|
/// Map SchemaType to its origin string for IssueCode payloads.
pub fn type_origin(t : SchemaType) -> String {
match t {
StringType => "string"
NumberType => "number"
BooleanType => "boolean"
NullType => "null"
AnyType => "any"
UnknownType => "unknown"
ObjectType(_, _) => "object"
ArrayType(_) => "array"
TupleType(_) => "tuple"
EnumType(_) => "enum"
UnionType(_) => "union"
IntersectionType(_) => "intersection"
LiteralType(_) => "literal"
TransformType(_, _) => "transform"
PreprocessType(_, _) => "preprocess"
OptionalType(_) => "optional"
DefaultType(_, _) => "default"
}
}

View File

@ -30,7 +30,7 @@ pub fn Schema::parse_intersection(
schemas : Array[Schema],
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
fn merge_json(a : Json, b : Json) -> Json {
match (a, b) {
(Object(map_a), Object(map_b)) => {
@ -42,20 +42,20 @@ pub fn Schema::parse_intersection(
_ => a
}
}
let errors : Array[ValidationError] = []
let raw : Array[RawIssue] = []
let mut merged = json
for s in schemas {
match parse_inner(s, json, path_stack) {
Ok(result) => merged = merge_json(merged, result)
Err(errs) =>
for e in errs {
errors.push(e)
raw.push(e)
}
}
}
if errors.is_empty() {
if raw.is_empty() {
Ok(merged)
} else {
Err(errors)
Err(raw)
}
}

View File

@ -30,7 +30,7 @@ pub fn Schema::parse_literal(
expected : Json,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
let path = format_path(path_stack)
if json == expected {
Ok(json)
@ -41,7 +41,14 @@ pub fn Schema::parse_literal(
} else {
_self.invalid_type_error
}
Err([ValidationError::{ path, message, got: json }])
Err([
RawIssue::{
code: IssueCode::InvalidValue([expected]),
path,
message,
input: json,
},
])
}
}
@ -59,7 +66,7 @@ fn json_to_literal_string(json : Json) -> String {
False => "false"
Null => "null"
Array(arr) => {
let parts = arr.map(fn(v) { json_to_literal_string(v) })
let parts : Array[String] = arr.map(fn(v) { json_to_literal_string(v) })
"[" + parts.join(", ") + "]"
}
Object(map) => {

View File

@ -32,6 +32,7 @@ pub fn Schema::int(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("integer"),
Json::object({ "type": Json::string("integer") }),
)
}
@ -53,6 +54,7 @@ pub fn Schema::positive(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::TooSmall("number", 0.0, false),
Json::object({ "exclusiveMinimum": Json::number(0.0) }),
)
}
@ -74,6 +76,7 @@ pub fn Schema::negative(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::TooBig("number", 0.0, false),
Json::object({ "exclusiveMaximum": Json::number(0.0) }),
)
}
@ -100,6 +103,7 @@ pub fn Schema::multipleOf(self : Schema, n : Int, msg? : String = "") -> Schema
}
},
message,
IssueCode::NotMultipleOf(n.to_double()),
Json::object({ "multipleOf": Json::number(n.to_double()) }),
)
}
@ -125,6 +129,7 @@ pub fn Schema::finite(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::Custom,
)
}
@ -152,5 +157,6 @@ pub fn Schema::safe(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::Custom,
)
}

View File

@ -230,10 +230,10 @@ pub fn Schema::parse_object(
mode : ObjectMode,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match json {
Object(input_map) => {
let errors : Array[ValidationError] = []
let raw : Array[RawIssue] = []
let parsed_fields : Map[String, Json] = Map([])
for field_name in spec.keys() {
match spec.get(field_name) {
@ -248,18 +248,19 @@ pub fn Schema::parse_object(
} else {
"Required"
}
errors.push(ValidationError::{
raw.push(RawIssue::{
code: IssueCode::MissingRequired(field_name.to_string()),
path,
message: req_msg,
got: Json::null(),
input: Json::null(),
})
}
Some(field_json) => {
let result = parse_inner(field_schema, field_json, path_stack)
match result {
Err(field_errors) =>
for e in field_errors {
errors.push(e)
Err(field_raw) =>
for e in field_raw {
raw.push(e)
}
Ok(parsed) => parsed_fields.set(field_name, parsed)
}
@ -279,10 +280,11 @@ pub fn Schema::parse_object(
path_stack.push(key)
let path = format_path(path_stack)
let _ = path_stack.pop()
errors.push(ValidationError::{
raw.push(RawIssue::{
code: IssueCode::UnrecognizedKeys([key]),
path,
message: "Unexpected field",
got: value,
input: value,
})
}
None => ()
@ -290,19 +292,27 @@ pub fn Schema::parse_object(
}
_ => ()
}
collect_errors(errors, path_stack, json, self.rules)
if errors.is_empty() {
collect_raw_errors(raw, path_stack, json, self.rules)
if raw.is_empty() {
match mode {
Strip => Ok(Json::object(parsed_fields))
_ => Ok(json)
}
} else {
Err(errors)
Err(raw)
}
}
_ => {
let path = format_path(path_stack)
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
let message = type_msg(self)
Err([
RawIssue::{
code: IssueCode::InvalidType(type_origin(self.schema_type)),
path,
message,
input: json,
},
])
}
}
}

View File

@ -20,7 +20,7 @@ pub fn Schema::parse_optional(
inner : Schema,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match json {
Null => Ok(json)
_ => parse_inner(inner, json, path_stack)

View File

@ -27,22 +27,24 @@ pub fn Schema::parse_preprocess(
inner : Schema,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match (closure.f)(json) {
Err(msg) => {
let path = format_path(path_stack)
Err([ValidationError::{ path, message: msg, got: json }])
Err([
RawIssue::{ code: IssueCode::Custom, path, message: msg, input: json },
])
}
Ok(preprocessed) =>
match parse_inner(inner, preprocessed, path_stack) {
Err(e) => Err(e)
Ok(parsed) => {
let errors : Array[ValidationError] = []
collect_errors(errors, path_stack, parsed, self.rules)
if errors.is_empty() {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, parsed, self.rules)
if raw.is_empty() {
Ok(parsed)
} else {
Err(errors)
Err(raw)
}
}
}

View File

@ -8,5 +8,5 @@ pub fn Schema::refine(
check : (Json) -> Bool,
message : String,
) -> Schema {
append_rule(self, check, message)
append_rule(self, check, message, IssueCode::Custom)
}

View File

@ -39,6 +39,7 @@ pub(all) struct TransformClosure {
pub(all) struct Rule {
check : (Json) -> Bool
message : String
code : IssueCode
annotation : Json
} derive(Debug)
@ -176,8 +177,9 @@ pub fn append_rule(
schema : Schema,
check : (Json) -> Bool,
message : String,
code : IssueCode,
) -> Schema {
append_rule_with_annotation(schema, check, message, Json::null())
append_rule_with_annotation(schema, check, message, code, Json::null())
}
///|
@ -188,12 +190,13 @@ pub fn append_rule_with_annotation(
schema : Schema,
check : (Json) -> Bool,
message : String,
code : IssueCode,
annotation : Json,
) -> Schema {
match schema.schema_type {
OptionalType(inner) => {
let new_inner = append_rule_with_annotation(
inner, check, message, annotation,
inner, check, message, code, annotation,
)
{
schema_type: OptionalType(new_inner),
@ -207,7 +210,7 @@ pub fn append_rule_with_annotation(
}
DefaultType(inner, default_val) => {
let new_inner = append_rule_with_annotation(
inner, check, message, annotation,
inner, check, message, code, annotation,
)
{
schema_type: DefaultType(new_inner, default_val),
@ -219,53 +222,24 @@ pub fn append_rule_with_annotation(
brand: schema.brand,
}
}
_ => { ..schema, rules: schema.rules + [{ check, message, annotation }] }
_ =>
{ ..schema, rules: schema.rules + [{ check, message, code, annotation }] }
}
}
///|
fn type_error_msg(schema : Schema) -> String {
///|
fn type_msg(schema : Schema) -> String {
let default = "Expected " + type_origin(schema.schema_type)
if !schema.invalid_type_error.is_empty() {
schema.invalid_type_error
} else {
expected_msg(schema.schema_type)
default
}
}
///|
fn expected_msg(schema_type : SchemaType) -> String {
match schema_type {
StringType => "Expected string"
NumberType => "Expected number"
BooleanType => "Expected boolean"
NullType => "Expected null"
AnyType => "Expected any"
UnknownType => "Expected unknown"
ObjectType(_, _) => "Expected object"
ArrayType(_) => "Expected array"
TupleType(_) => "Expected tuple"
EnumType(_) => "Invalid enum value"
IntersectionType(_) => "Expected intersection match"
TransformType(_, _) => "Validation failed"
LiteralType(_) => "Expected literal value"
_ => "Validation failed"
}
}
///|
fn collect_errors(
errors : Array[ValidationError],
path_stack : Array[String],
json : Json,
rules : Array[Rule],
) -> Unit {
let path = format_path(path_stack)
for rule in rules {
if !(rule.check)(json) {
errors.push(ValidationError::{ path, message: rule.message, got: json })
}
}
}
///|
pub fn format_path(stack : Array[String]) -> String {
@ -317,21 +291,19 @@ fn parse_inner(
schema : Schema,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
// All parse helpers are called through this function so the same
// mutable path_stack is shared across the entire call tree.
) -> RawSchemaResult {
match schema.schema_type {
ObjectType(spec, mode) => schema.parse_object(spec, mode, json, path_stack)
ArrayType(element_schema) =>
schema.parse_array(element_schema, json, path_stack)
TupleType(items) => schema.parse_tuple(items, json, path_stack)
AnyType | UnknownType => {
let errors : Array[ValidationError] = []
collect_errors(errors, path_stack, json, schema.rules)
if errors.is_empty() {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, json, schema.rules)
if raw.is_empty() {
Ok(json)
} else {
Err(errors)
Err(raw)
}
}
OptionalType(inner) => schema.parse_optional(inner, json, path_stack)
@ -356,16 +328,22 @@ fn parse_inner(
}
if !valid {
let path = format_path(path_stack)
let message = type_msg(schema)
return Err([
ValidationError::{ path, message: type_error_msg(schema), got: json },
RawIssue::{
code: IssueCode::InvalidType(type_origin(schema.schema_type)),
path,
message,
input: json,
},
])
}
let errors : Array[ValidationError] = []
collect_errors(errors, path_stack, json, schema.rules)
if errors.is_empty() {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, json, schema.rules)
if raw.is_empty() {
Ok(json)
} else {
Err(errors)
Err(raw)
}
}
}
@ -381,8 +359,33 @@ pub fn Schema::parse(
json : Json,
path? : String = "",
) -> SchemaResult {
let path_stack : Array[String] = if path.is_empty() { [] } else { [path] }
parse_inner(self, json, path_stack)
Schema::safe_parse(self, json, ParseParams::{ path, error_map: None })
}
///|
/// Validate `json` against this schema with extended parameters.
/// Supports contextual error map override.
///
/// # Example
/// ```mbt nocheck
/// schema.safe_parse(json, { error_map: fn(code, path, input) {
/// match code { InvalidType(expected) => Some("必须是: " + expected) _ => None }
/// }})
/// ```
pub fn Schema::safe_parse(
self : Schema,
json : Json,
params : ParseParams,
) -> SchemaResult {
let path_stack : Array[String] = if params.path.is_empty() {
[]
} else {
[params.path]
}
match parse_inner(self, json, path_stack) {
Ok(v) => Ok(v)
Err(raw) => Err(finalize_issues(raw, params))
}
}
///|

View File

@ -64,7 +64,19 @@ pub fn Schema::min(self : Schema, n : Int, msg? : String = "") -> Schema {
ArrayType(_) => Json::object({ "minItems": Json::number(n.to_double()) })
_ => Json::null()
}
append_rule_with_annotation(self, check, message, annotation)
let origin = match inner_type(self.schema_type) {
StringType => "string"
NumberType => "number"
ArrayType(_) => "array"
_ => "value"
}
append_rule_with_annotation(
self,
check,
message,
IssueCode::TooSmall(origin, n.to_double(), true),
annotation,
)
}
///|
@ -116,7 +128,19 @@ pub fn Schema::max(self : Schema, n : Int, msg? : String = "") -> Schema {
ArrayType(_) => Json::object({ "maxItems": Json::number(n.to_double()) })
_ => Json::null()
}
append_rule_with_annotation(self, check, message, annotation)
let origin = match inner_type(self.schema_type) {
StringType => "string"
NumberType => "number"
ArrayType(_) => "array"
_ => "value"
}
append_rule_with_annotation(
self,
check,
message,
IssueCode::TooBig(origin, n.to_double(), true),
annotation,
)
}
///|
@ -256,6 +280,7 @@ pub fn Schema::email(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("email"),
Json::object({ "format": Json::string("email") }),
)
}
@ -409,6 +434,7 @@ pub fn Schema::url(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("url"),
Json::object({ "format": Json::string("uri") }),
)
}
@ -441,6 +467,7 @@ pub fn Schema::regex(
}
},
message,
IssueCode::InvalidFormat("regex"),
Json::object({ "pattern": Json::string(pattern) }),
)
}
@ -470,6 +497,7 @@ pub fn Schema::startsWith(
}
},
message,
IssueCode::Custom,
Json::object({ "pattern": Json::string("^" + prefix) }),
)
}
@ -499,6 +527,7 @@ pub fn Schema::endsWith(
}
},
message,
IssueCode::Custom,
Json::object({ "pattern": Json::string(suffix + "$") }),
)
}
@ -528,6 +557,7 @@ pub fn Schema::includes(
}
},
message,
IssueCode::Custom,
)
}
@ -578,7 +608,6 @@ pub fn Schema::uuid(self : Schema, msg? : String = "") -> Schema {
chars[19] != 'B' {
return false
}
// All other chars must be hex digits
let positions = [
0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25,
26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
@ -594,6 +623,7 @@ pub fn Schema::uuid(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("uuid"),
Json::object({ "format": Json::string("uuid") }),
)
}
@ -630,6 +660,7 @@ pub fn Schema::cuid(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("cuid"),
Json::object({ "format": Json::string("cuid") }),
)
}
@ -749,6 +780,7 @@ pub fn Schema::datetime(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("datetime"),
Json::object({ "format": Json::string("date-time") }),
)
}
@ -894,6 +926,7 @@ pub fn Schema::ipv4(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("ipv4"),
Json::object({ "format": Json::string("ipv4") }),
)
}
@ -919,6 +952,7 @@ pub fn Schema::ipv6(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("ipv6"),
Json::object({ "format": Json::string("ipv6") }),
)
}
@ -944,6 +978,7 @@ pub fn Schema::ip(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("ip"),
)
}
@ -991,6 +1026,12 @@ pub fn Schema::nonempty(self : Schema, msg? : String = "") -> Schema {
_ => "Array must not be empty"
}
let message = if msg.is_empty() { default_msg } else { msg }
let origin = match inner_type(self.schema_type) {
StringType => "string"
ArrayType(_) => "array"
TupleType(_) => "tuple"
_ => "value"
}
append_rule(
self,
fn(json) {
@ -1001,6 +1042,7 @@ pub fn Schema::nonempty(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::TooSmall(origin, 1.0, true),
)
}
@ -1055,7 +1097,7 @@ pub fn Schema::length(self : Schema, n : Int, msg? : String = "") -> Schema {
let check = schema_length_check(self, n)
let message = if msg.is_empty() { schema_length_msg(self, n) } else { msg }
// No annotation — avoids conflict with minLength/maxLength
append_rule(self, check, message)
append_rule(self, check, message, IssueCode::Custom)
}
///|
@ -1098,6 +1140,7 @@ pub fn Schema::ulid(self : Schema, msg? : String = "") -> Schema {
}
},
message,
IssueCode::InvalidFormat("ulid"),
Json::object({ "format": Json::string("ulid") }),
)
}

View File

@ -38,23 +38,30 @@ pub fn Schema::parse_transform(
closure : TransformClosure,
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match parse_inner(inner, json, path_stack) {
Err(e) => Err(e)
Ok(validated) =>
match (closure.f)(validated) {
Ok(transformed) => {
let errors : Array[ValidationError] = []
collect_errors(errors, path_stack, transformed, _self.rules)
if errors.is_empty() {
let raw : Array[RawIssue] = []
collect_raw_errors(raw, path_stack, transformed, _self.rules)
if raw.is_empty() {
Ok(transformed)
} else {
Err(errors)
Err(raw)
}
}
Err(msg) => {
let path = format_path(path_stack)
Err([ValidationError::{ path, message: msg, got: json }])
Err([
RawIssue::{
code: IssueCode::Custom,
path,
message: msg,
input: json,
},
])
}
}
}

View File

@ -24,40 +24,61 @@ pub fn Schema::parse_tuple(
items : Array[Schema],
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
match json {
Array(elements) => {
let errors : Array[ValidationError] = []
if elements.length() != items.length() {
let raw : Array[RawIssue] = []
let actual_len = elements.length()
let expected_len = items.length()
if actual_len < expected_len {
let path = format_path(path_stack)
errors.push(ValidationError::{
let msg = "Expected tuple of length \{expected_len}"
raw.push(RawIssue::{
code: IssueCode::TooSmall("tuple", expected_len.to_double(), true),
path,
message: "Expected tuple of length \{items.length()}",
got: json,
message: msg,
input: json,
})
} else if actual_len > expected_len {
let path = format_path(path_stack)
let msg = "Expected tuple of length \{expected_len}"
raw.push(RawIssue::{
code: IssueCode::TooBig("tuple", expected_len.to_double(), true),
path,
message: msg,
input: json,
})
} else {
for i in 0..<items.length() {
path_stack.push("[\{i}]")
match parse_inner(items[i], elements[i], path_stack) {
Err(item_errors) =>
for e in item_errors {
errors.push(e)
Err(item_raw) =>
for e in item_raw {
raw.push(e)
}
_ => ()
}
let _ = path_stack.pop()
}
}
collect_errors(errors, path_stack, json, self.rules)
if errors.is_empty() {
collect_raw_errors(raw, path_stack, json, self.rules)
if raw.is_empty() {
Ok(json)
} else {
Err(errors)
Err(raw)
}
}
_ => {
let path = format_path(path_stack)
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
let message = type_msg(self)
Err([
RawIssue::{
code: IssueCode::InvalidType(type_origin(self.schema_type)),
path,
message,
input: json,
},
])
}
}
}

View File

@ -1,14 +1,16 @@
///|
/// Represents a single validation failure.
///
/// `code` is the structured issue code for machine-readable error classification.
/// `path` is the field path (e.g. `"name"`, `"address.city"`).
/// `message` describes what went wrong.
/// `got` is the actual JSON value that failed validation.
pub(all) struct ValidationError {
code : IssueCode
path : String
message : String
got : Json
}
} derive(Debug)
///|
/// Format a validation error as a human-readable string.
@ -18,7 +20,9 @@ pub fn ValidationError::to_string(self : ValidationError) -> String {
self.path +
"] " +
self.message +
" (got: " +
" (code: " +
@debug.to_string(self.code) +
", got: " +
@debug.to_string(self.got) +
")"
}
@ -27,3 +31,7 @@ pub fn ValidationError::to_string(self : ValidationError) -> String {
/// Result type returned by `Schema::parse`.
/// `Ok(json)` on success, `Err(errors)` with all collected errors on failure.
pub type SchemaResult = Result[Json, Array[ValidationError]]
///|
/// Internal result type used during parsing before error message finalization.
pub type RawSchemaResult = Result[Json, Array[RawIssue]]

View File

@ -23,15 +23,12 @@ pub fn Schema::parse_union(
schemas : Array[Schema],
json : Json,
path_stack : Array[String],
) -> SchemaResult {
) -> RawSchemaResult {
let all_branch_errors : Array[String] = []
for s in schemas {
match parse_inner(s, json, path_stack) {
Ok(v) => return Ok(v)
Err(errors) =>
if errors.length() > 0 {
all_branch_errors.push(errors[0].message)
}
Err(raw) => if raw.length() > 0 { all_branch_errors.push(raw[0].message) }
}
}
let path = format_path(path_stack)
@ -39,5 +36,12 @@ pub fn Schema::parse_union(
let message = "Expected union type, but all branches failed. Branches: [" +
branches +
"]"
Err([ValidationError::{ path, message, got: json }])
Err([
RawIssue::{
code: IssueCode::InvalidUnion(all_branch_errors),
path,
message,
input: json,
},
])
}

View File

@ -56,7 +56,7 @@ moon_zod/
├── importers/ # Schema import tools
│ └── from_json_schema.mbt # json_schema_to_moon_zod() — reverse JSON Schema → moon_zod code generation
├── tests/ # Test suite (466 tests)
├── tests/ # Test suite (479 tests)
│ ├── test_string.mbt # string() validator tests (trim, to_lower, to_upper, nonempty)
│ ├── test_number.mbt # number() validator tests
│ ├── test_boolean_null.mbt # boolean/null tests
@ -104,7 +104,7 @@ moon_zod/
```bash
# Testing & Building
moon test # Run all tests (466 total, 0 warnings)
moon test # Run all tests (479 total, 0 warnings)
moon build # Build the library
moon check # Type check (0 errors, 0 warnings)
moon info && moon fmt # Update interface + format

View File

@ -56,7 +56,7 @@ moon_zod/
├── importers/ # Schema 导入工具
│ └── from_json_schema.mbt # json_schema_to_moon_zod() —— 反向 JSON Schema → moon_zod 代码生成
├── tests/ # 测试套件466 个测试)
├── tests/ # 测试套件479 个测试)
│ ├── test_string.mbt # string() 校验器测试trim、to_lower、to_upper、nonempty
│ ├── test_number.mbt # number() 校验器测试
│ ├── test_boolean_null.mbt # boolean/null 测试
@ -104,7 +104,7 @@ moon_zod/
```bash
# 测试与构建
moon test # 运行所有测试(共 4660 警告)
moon test # 运行所有测试(共 4790 警告)
moon build # 构建库
moon check # 类型检查0 错误0 警告)
moon info && moon fmt # 更新接口 + 格式化

View File

@ -148,6 +148,7 @@ fn json_to_schema_impl(
}
},
"Value must be one of the allowed numbers",
@core.IssueCode::Custom,
)
return result_schema
}
@ -320,6 +321,7 @@ fn apply_json_schema_constraints(
}
},
"Value must be greater than \{v}",
@core.IssueCode::Custom,
)
}
}
@ -344,6 +346,7 @@ fn apply_json_schema_constraints(
}
},
"Value must be less than \{v}",
@core.IssueCode::Custom,
)
}
}

View File

@ -18,6 +18,14 @@ pub using @core {
type ValidationError,
type SchemaResult,
type ConstraintInfo,
type IssueCode,
type RawIssue,
type ParseParams,
type ErrorMap,
type RawSchemaResult,
finalize_issue,
finalize_issues,
collect_raw_errors,
string,
number,
boolean,

251
tests/test_error_map.mbt Normal file
View File

@ -0,0 +1,251 @@
///|
test "safe_parse accepts valid input" {
let s = string().min(3)
let params = ParseParams::default()
match s.safe_parse(Json::string("abc"), params) {
Ok(v) => @debug.assert_eq(v, Json::string("abc"))
Err(_) => fail("expected Ok")
}
}
///|
test "safe_parse returns errors for invalid input" {
let s = string().min(3)
let params = ParseParams::default()
guard s.safe_parse(Json::string("ab"), params) is Err(_) else {
fail("expected Err")
}
}
///|
test "error_map overrides type error message" {
let s = string()
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("必须是字符串")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "必须是字符串")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map overrides missing required message" {
let s = object({ "name": string() })
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::MissingRequired(_) => Some("缺少姓名")
_ => None
}
}),
}
match s.safe_parse(parse_json("{}"), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "缺少姓名")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map overrides union error message" {
let s = union([string(), number()])
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidUnion(_) => Some("必须是字符串或数字")
_ => None
}
}),
}
match s.safe_parse(Json::boolean(true), params) {
Err(errors) =>
@debug.assert_eq(errors[0].message, "必须是字符串或数字")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map does not affect other parses" {
let s = string()
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("custom type error")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "custom type error")
Ok(_) => fail("expected Err")
}
let default_params = ParseParams::default()
match s.safe_parse(Json::number(42.0), default_params) {
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map none uses default message" {
let s = string()
let params = ParseParams::default()
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map empty string falls back to inline or schema default" {
let s = string(invalid_type_error="custom type error")
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "custom type error")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map empty string falls back to hardcoded default" {
let s = string()
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map multiple errors in one parse" {
let s = object({ "name": string(), "age": number().int() })
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::MissingRequired(_) => Some("缺少必填字段")
IssueCode::InvalidType(_) => Some("类型错误")
_ => None
}
}),
}
let input = parse_json("{\"name\": true}")
match s.safe_parse(input, params) {
Err(errors) => {
@debug.assert_eq(errors.length(), 2)
let messages = errors.map(fn(e) { e.message })
@debug.assert_eq(messages.contains("缺少必填字段"), true)
@debug.assert_eq(messages.contains("类型错误"), true)
}
Ok(_) => fail("expected Err")
}
}
///|
test "error_map priority over invalid_type_error" {
let s = string(invalid_type_error="schema type error")
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("map type error")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "map type error")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map nested schema" {
let s = object({ "user": object({ "age": number().int() }) })
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(expected) => Some("类型错误: " + expected)
_ => None
}
}),
}
let input = parse_json("{\"user\": {\"age\": \"x\"}}")
match s.safe_parse(input, params) {
Err(errors) => @debug.assert_eq(errors[0].message, "类型错误: number")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map array elements" {
let s = array(number().int())
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(expected) => Some("需要: " + expected)
_ => None
}
}),
}
let input = parse_json("[1, \"two\"]")
match s.safe_parse(input, params) {
Err(errors) => @debug.assert_eq(errors[0].message, "需要: number")
Ok(_) => fail("expected Err")
}
}
///|
test "error_map priority over type error inline" {
let s = string(invalid_type_error="custom type error")
let params = ParseParams::{
path: "",
error_map: Some(fn(code, _path, _input) {
match code {
IssueCode::InvalidType(_) => Some("map type error")
_ => None
}
}),
}
match s.safe_parse(Json::number(42.0), params) {
Err(errors) => @debug.assert_eq(errors[0].message, "map type error")
Ok(_) => fail("expected Err")
}
}
///|
test "safe_parse with path prefix" {
let s = string().min(3)
let params = ParseParams::{ path: "user.name", error_map: None }
match s.safe_parse(Json::string("ab"), params) {
Err(errors) => @debug.assert_eq(errors[0].path, "user.name")
Ok(_) => fail("expected Err")
}
}

172
tests/test_issue_code.mbt Normal file
View File

@ -0,0 +1,172 @@
///|
test "validation_error has code field" {
let s = string()
guard s.parse(Json::number(42.0)) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("string"))
}
///|
test "issue_code invalid_type string" {
let s = string()
guard s.parse(Json::number(42.0)) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("string"))
}
///|
test "issue_code invalid_type number" {
let s = number()
guard s.parse(Json::string("abc")) is Err(errors) else {
fail("expected Err")
}
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
}
///|
test "issue_code invalid_type boolean" {
let s = boolean()
guard s.parse(Json::string("true")) is Err(errors) else {
fail("expected Err")
}
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("boolean"))
}
///|
test "issue_code invalid_type object" {
let s = object(Map([]))
guard s.parse(Json::string("hello")) is Err(errors) else {
fail("expected Err")
}
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("object"))
}
///|
test "issue_code invalid_type array" {
let s = array(string())
guard s.parse(Json::string("hello")) is Err(errors) else {
fail("expected Err")
}
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("array"))
}
///|
test "issue_code rule_error_too_small" {
let s = string().min(3)
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::TooSmall("string", 3.0, true))
}
///|
test "issue_code email_rule_invalid_format" {
let s = string().email()
guard s.parse(Json::string("not-an-email")) is Err(errors) else {
fail("expected Err")
}
@debug.assert_eq(errors[0].code, IssueCode::InvalidFormat("email"))
}
///|
test "issue_code refine is custom" {
let s = string().refine(
fn(s) {
match s {
Json::String(str) => str.length() > 3
_ => false
}
},
"too short",
)
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::Custom)
@debug.assert_eq(errors[0].message, "too short")
}
///|
test "issue_code missing_required" {
let s = object({ "name": string() })
let input = parse_json("{}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::MissingRequired("name"))
}
///|
test "issue_code missing_required custom message" {
let s = object({ "name": string(required_error="姓名必填") })
let input = parse_json("{}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::MissingRequired("name"))
@debug.assert_eq(errors[0].message, "姓名必填")
}
///|
test "issue_code invalid_union" {
let s = union([string(), number()])
guard s.parse(Json::boolean(true)) is Err(errors) else {
fail("expected Err")
}
match errors[0].code {
IssueCode::InvalidUnion(_) => ()
_ => fail("expected InvalidUnion")
}
}
///|
test "issue_code unrecognized_keys strict" {
let s = object({ "name": string() }).strict()
let input = parse_json("{\"name\": \"Alice\", \"extra\": 1}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].code, IssueCode::UnrecognizedKeys(["extra"]))
}
///|
test "issue_code invalid_value enum" {
let s = enum_values(["red", "green", "blue"])
guard s.parse(Json::string("yellow")) is Err(errors) else {
fail("expected Err")
}
match errors[0].code {
IssueCode::InvalidValue(_) => ()
_ => fail("expected InvalidValue")
}
}
///|
test "issue_code invalid_value literal" {
let s = literal(Json::string("expected"))
guard s.parse(Json::string("actual")) is Err(errors) else {
fail("expected Err")
}
match errors[0].code {
IssueCode::InvalidValue(_) => ()
_ => fail("expected InvalidValue")
}
}
///|
test "issue_code nested object path" {
let s = object({ "user": object({ "age": number().int() }) })
let input = parse_json("{\"user\": {\"age\": \"x\"}}")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].path, "user.age")
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
}
///|
test "issue_code array_index_path" {
let s = array(number().int())
let input = parse_json("[1, \"two\", 3]")
guard s.parse(input) is Err(errors) else { fail("expected Err") }
@debug.assert_eq(errors[0].path, "[1]")
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
}
///|
test "issue_code optional_missing_no_error" {
let s = object({ "name": string().optional() })
guard s.parse(parse_json("{}")) is Ok(_) else { fail("expected Ok") }
}
///|
test "issue_code default_uses_default_no_error" {
let s = object({ "name": string().default("anon") })
guard s.parse(parse_json("{}")) is Ok(_) else { fail("expected Ok") }
}

View File

@ -226,10 +226,9 @@ test "schema_to_prompt refine custom message" {
}
///|
test "schema_to_prompt nonempty is filtered" {
// nonempty has no annotation and its message matches the built-in
test "schema_to_prompt nonempty shows min constraint" {
let s = string().nonempty()
@debug.assert_eq(schema_to_prompt(s), "string")
@debug.assert_eq(schema_to_prompt(s), "string // [min: 1]")
}
///|