use std::cell::RefCell;
use std::collections::HashMap;
use regex::RegexBuilder;
use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::pos::Span;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
use crate::scalar_helpers;
use crate::schema::{AdditionalProperties, JsonSchema, SchemaType};
fn entries_contains_key(entries: &[(Node<Span>, Node<Span>)], key: &str) -> bool {
entries
.iter()
.any(|(k, _)| matches!(k, Node::Scalar { value, .. } if value == key))
}
fn node_key_str(node: &Node<Span>) -> Option<String> {
match node {
Node::Scalar { value, .. } => Some(value.clone()),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => None,
}
}
const MAX_PATTERN_LEN: usize = 1024;
const REGEX_SIZE_LIMIT: usize = 512 * 1024;
thread_local! {
static REGEX_CACHE: RefCell<HashMap<String, Option<regex::Regex>>> =
RefCell::new(HashMap::new());
}
fn get_regex(pattern: &str) -> Option<regex::Regex> {
REGEX_CACHE.with(|cache| {
let mut map = cache.borrow_mut();
if let Some(entry) = map.get(pattern) {
return entry.clone();
}
let compiled = RegexBuilder::new(pattern)
.size_limit(REGEX_SIZE_LIMIT)
.build()
.ok();
map.insert(pattern.to_string(), compiled.clone());
compiled
})
}
const MAX_VALIDATION_DEPTH: usize = 64;
const MAX_BRANCH_COUNT: usize = 20;
const MAX_DESCRIPTION_LEN: usize = 200;
const MAX_ENUM_DISPLAY: usize = 5;
fn collect_evaluated_properties(schema: &JsonSchema, key: &str) -> bool {
if schema
.properties
.as_ref()
.is_some_and(|p| p.contains_key(key))
{
return true;
}
if let Some(pp) = &schema.pattern_properties {
for (pattern, _) in pp {
if pattern.len() <= MAX_PATTERN_LEN {
if let Some(re) = get_regex(pattern) {
if re.is_match(key) {
return true;
}
}
}
}
}
if let Some(all_of) = &schema.all_of {
for branch in all_of.iter().take(MAX_BRANCH_COUNT) {
if collect_evaluated_properties(branch, key) {
return true;
}
}
}
if let Some(any_of) = &schema.any_of {
for branch in any_of.iter().take(MAX_BRANCH_COUNT) {
if collect_evaluated_properties(branch, key) {
return true;
}
}
}
if let Some(one_of) = &schema.one_of {
for branch in one_of.iter().take(MAX_BRANCH_COUNT) {
if collect_evaluated_properties(branch, key) {
return true;
}
}
}
if let Some(then_s) = &schema.then_schema {
if collect_evaluated_properties(then_s, key) {
return true;
}
}
if let Some(else_s) = &schema.else_schema {
if collect_evaluated_properties(else_s, key) {
return true;
}
}
false
}
fn collect_evaluated_item_count(schema: &JsonSchema) -> usize {
let mut count = schema.prefix_items.as_ref().map_or(0, Vec::len);
if schema.items.is_some() {
return usize::MAX;
}
if let Some(all_of) = &schema.all_of {
for branch in all_of.iter().take(MAX_BRANCH_COUNT) {
let branch_count = collect_evaluated_item_count(branch);
if branch_count == usize::MAX {
return usize::MAX;
}
count = count.max(branch_count);
}
}
count
}
struct Ctx<'a> {
diagnostics: &'a mut Vec<Diagnostic>,
format_validation: bool,
key_index: &'a HashMap<String, Range>,
}
impl<'a> Ctx<'a> {
const fn new(
diagnostics: &'a mut Vec<Diagnostic>,
format_validation: bool,
key_index: &'a HashMap<String, Range>,
) -> Self {
Self {
diagnostics,
format_validation,
key_index,
}
}
}
#[must_use]
pub fn validate_schema(
text: &str,
docs: &[Document<Span>],
schema: &JsonSchema,
format_validation: bool,
) -> Vec<Diagnostic> {
let lines: Vec<&str> = text.lines().collect();
let mut diagnostics = Vec::new();
let key_index = build_key_index(&lines);
let mut ctx = Ctx::new(&mut diagnostics, format_validation, &key_index);
for doc in docs {
validate_node(&doc.root, schema, &[], &mut ctx, 0);
}
diagnostics
}
fn build_key_index(lines: &[&str]) -> HashMap<String, Range> {
let mut index = HashMap::new();
for (line_idx, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
let col_offset = line.len() - trimmed.len();
let candidate = trimmed.strip_prefix("- ").unwrap_or(trimmed);
let Some(colon_pos) = candidate.find(':') else {
continue;
};
let key = candidate[..colon_pos].trim_end();
if key.is_empty() {
continue;
}
let col = u32::try_from(col_offset).unwrap_or(0);
let end_col = col + u32::try_from(key.len()).unwrap_or(0);
let line_u32 = u32::try_from(line_idx).unwrap_or(0);
let range = Range::new(
Position::new(line_u32, col),
Position::new(line_u32, end_col),
);
index.entry(key.to_string()).or_insert(range);
}
index
}
fn validate_node(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
if depth > MAX_VALIDATION_DEPTH {
return;
}
if let Some(schema_type) = &schema.schema_type {
let yaml_type = yaml_type_name(node);
if !type_matches(yaml_type, schema_type) {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaType",
format!(
"Value at {} does not match type: expected {}, got {}",
format_path(path),
display_schema_type(schema_type),
yaml_type,
),
));
return;
}
}
if let Some(enum_values) = &schema.enum_values
&& let Some(yaml_val) = yaml_to_json(node)
&& !enum_values.contains(&yaml_val)
{
let range = node_range(path, ctx.key_index);
let listed: Vec<String> = enum_values
.iter()
.take(MAX_ENUM_DISPLAY)
.map(ToString::to_string)
.collect();
let valid = if enum_values.len() > MAX_ENUM_DISPLAY {
format!(
"{}, ... and {} more",
listed.join(", "),
enum_values.len() - MAX_ENUM_DISPLAY
)
} else {
listed.join(", ")
};
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaEnum",
format!("Value at {} must be one of: {}", format_path(path), valid),
));
}
validate_scalar_constraints(node, schema, path, ctx);
if let Node::Mapping { entries, .. } = node {
validate_mapping(entries, schema, path, ctx, depth);
}
if let Node::Sequence { items, .. } = node {
validate_sequence(items, schema, path, ctx, depth);
}
validate_composition(node, schema, path, ctx, depth);
if schema.unevaluated_properties.is_some() {
if let Node::Mapping { entries, .. } = node {
validate_unevaluated_properties(entries, schema, path, ctx, depth);
}
}
if schema.unevaluated_items.is_some() {
if let Node::Sequence { items, .. } = node {
validate_unevaluated_items(items, schema, path, ctx, depth);
}
}
}
fn validate_unevaluated_properties(
entries: &[(Node<Span>, Node<Span>)],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
for (k, v) in entries {
let Some(key_str) = node_key_str(k) else {
continue;
};
if collect_evaluated_properties(schema, &key_str) {
continue;
}
match &schema.unevaluated_properties {
Some(AdditionalProperties::Denied) => {
let range = key_range(&key_str, path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaUnevaluatedProperty",
format!(
"Unevaluated property '{}' is not allowed at {}",
key_str,
format_path(path)
),
));
}
Some(AdditionalProperties::Schema(extra_schema)) => {
let mut child_path = path.to_vec();
child_path.push(key_str.clone());
validate_node(v, extra_schema, &child_path, ctx, depth + 1);
}
None => {}
}
}
}
fn validate_unevaluated_items(
seq: &[Node<Span>],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
let evaluated_count = collect_evaluated_item_count(schema);
let Some(unevaluated_schema) = &schema.unevaluated_items else {
return;
};
for (i, item) in seq.iter().enumerate() {
if evaluated_count == usize::MAX || i < evaluated_count {
continue;
}
let mut item_path = path.to_vec();
item_path.push(format!("[{i}]"));
validate_node(item, unevaluated_schema, &item_path, ctx, depth + 1);
}
}
fn validate_sequence(
seq: &[Node<Span>],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
let prefix_len = schema.prefix_items.as_ref().map_or(0, Vec::len);
if let Some(prefix_schemas) = &schema.prefix_items {
for (i, (item, item_schema)) in seq.iter().zip(prefix_schemas.iter()).enumerate() {
let mut item_path = path.to_vec();
item_path.push(format!("[{i}]"));
validate_node(item, item_schema, &item_path, ctx, depth + 1);
}
}
if let Some(items_schema) = &schema.items {
for (i, item) in seq.iter().enumerate().skip(prefix_len) {
let mut item_path = path.to_vec();
item_path.push(format!("[{i}]"));
validate_node(item, items_schema, &item_path, ctx, depth + 1);
}
} else if let Some(additional_items) = &schema.additional_items {
for (i, item) in seq.iter().enumerate().skip(prefix_len) {
let mut item_path = path.to_vec();
item_path.push(format!("[{i}]"));
match additional_items {
AdditionalProperties::Denied => {
let range = node_range(&item_path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaAdditionalItems",
format!(
"Additional item at {}[{i}] is not allowed",
format_path(path)
),
));
}
AdditionalProperties::Schema(extra_schema) => {
validate_node(item, extra_schema, &item_path, ctx, depth + 1);
}
}
}
}
validate_array_constraints(seq, schema, path, ctx, depth);
}
fn validate_array_constraints(
seq: &[Node<Span>],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
let len = seq.len() as u64;
if let Some(min) = schema.min_items {
if len < min {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMinItems",
format!(
"Array at {} has {} items, minimum is {}",
format_path(path),
len,
min
),
));
}
}
if let Some(max) = schema.max_items {
if len > max {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMaxItems",
format!(
"Array at {} has {} items, maximum is {}",
format_path(path),
len,
max
),
));
}
}
if schema.unique_items == Some(true) {
let json_items: Vec<serde_json::Value> = seq.iter().filter_map(yaml_to_json).collect();
let has_duplicate = json_items.iter().enumerate().any(|(i, a)| {
json_items
.get(..i)
.is_some_and(|prev| prev.iter().any(|b| a == b))
});
if has_duplicate {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaUniqueItems",
format!("Array at {} contains duplicate items", format_path(path)),
));
}
}
if let Some(contains_schema) = &schema.contains {
validate_contains(seq, contains_schema, schema, path, ctx, depth);
}
}
fn validate_mapping_constraints(
entries: &[(Node<Span>, Node<Span>)],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
let len = entries.len() as u64;
if let Some(min) = schema.min_properties {
if len < min {
let range = mapping_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMinProperties",
format!(
"Object at {} has {} properties, minimum is {}",
format_path(path),
len,
min
),
));
}
}
if let Some(max) = schema.max_properties {
if len > max {
let range = mapping_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMaxProperties",
format!(
"Object at {} has {} properties, maximum is {}",
format_path(path),
len,
max
),
));
}
}
}
fn validate_contains(
seq: &[Node<Span>],
contains_schema: &JsonSchema,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
let key_index = ctx.key_index;
let format_validation = ctx.format_validation;
let match_count = seq
.iter()
.filter(|item| {
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, key_index);
validate_node(item, contains_schema, path, &mut probe, depth + 1);
scratch.is_empty()
})
.count() as u64;
let effective_min = schema.min_contains.unwrap_or(1);
if match_count < effective_min {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaContains",
format!(
"Array at {} must contain at least {} item(s) matching the schema, found {}",
format_path(path),
effective_min,
match_count
),
));
}
if let Some(max) = schema.max_contains {
if match_count > max {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaContains",
format!(
"Array at {} must contain at most {} item(s) matching the schema, found {}",
format_path(path),
max,
match_count
),
));
}
}
}
fn validate_scalar_constraints(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
use rlsp_yaml_parser::event::ScalarStyle;
if let Node::Scalar { value, style, .. } = node {
let is_plain = matches!(style, ScalarStyle::Plain);
if !is_plain
|| (!scalar_helpers::is_null(value)
&& !scalar_helpers::is_bool(value)
&& !scalar_helpers::is_integer(value)
&& !scalar_helpers::is_float(value))
{
validate_string_constraints(value, schema, path, ctx);
}
if is_plain {
let numeric_val = scalar_helpers::parse_integer(value)
.map(|i| {
#[allow(clippy::cast_precision_loss)]
{
i as f64
}
})
.or_else(|| scalar_helpers::parse_float(value));
if let Some(val) = numeric_val {
validate_numeric_constraints(val, schema, path, ctx);
}
}
}
if let Some(const_val) = &schema.const_value {
if let Some(yaml_val) = yaml_to_json(node) {
if yaml_val != *const_val {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaConst",
format!("Value at {} must equal {}", format_path(path), const_val),
));
}
}
}
}
fn validate_string_constraints(s: &str, schema: &JsonSchema, path: &[String], ctx: &mut Ctx<'_>) {
if let Some(pattern) = &schema.pattern {
if pattern.len() > MAX_PATTERN_LEN {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaPatternLimit",
format!(
"Pattern at {} exceeds maximum length ({MAX_PATTERN_LEN} chars) and was not validated",
format_path(path),
),
));
} else if let Some(re) = get_regex(pattern) {
if !re.is_match(s) {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaPattern",
format!(
"Value at {} does not match pattern: {}",
format_path(path),
pattern
),
));
}
} else {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaPatternLimit",
format!(
"Pattern at {} could not be compiled and was not validated",
format_path(path),
),
));
}
}
let char_count = s.chars().count() as u64;
if let Some(min_len) = schema.min_length {
if char_count < min_len {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMinLength",
format!(
"Value at {} is too short: {} chars (minimum {})",
format_path(path),
char_count,
min_len
),
));
}
}
if let Some(max_len) = schema.max_length {
if char_count > max_len {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMaxLength",
format!(
"Value at {} is too long: {} chars (maximum {})",
format_path(path),
char_count,
max_len
),
));
}
}
if ctx.format_validation {
if let Some(format) = &schema.format {
validate_format(s, format, path, ctx.key_index, ctx.diagnostics);
}
if schema.content_encoding.is_some()
|| schema.content_media_type.is_some()
|| schema.content_schema.is_some()
{
validate_content(s, schema, path, ctx.key_index, ctx.diagnostics);
}
}
}
fn validate_format(
s: &str,
format: &str,
path: &[String],
key_index: &HashMap<String, Range>,
diagnostics: &mut Vec<Diagnostic>,
) {
let valid = match format {
"date-time" => is_valid_date_time(s),
"date" => is_valid_date(s),
"time" => is_valid_time(s),
"duration" => is_valid_duration(s),
"email" => is_valid_email(s),
"ipv4" => is_valid_ipv4(s),
"ipv6" => is_valid_ipv6(s),
"hostname" => is_valid_hostname(s),
"uri" => is_valid_uri(s),
"uri-reference" => is_valid_uri_reference(s),
"uri-template" => is_valid_uri_template(s),
"uuid" => is_valid_uuid(s),
"regex" => is_valid_regex(s),
"json-pointer" => is_valid_json_pointer(s),
"relative-json-pointer" => is_valid_relative_json_pointer(s),
"idn-hostname" => is_valid_idn_hostname(s),
"idn-email" => is_valid_idn_email(s),
"iri" => is_valid_iri(s),
"iri-reference" => is_valid_iri_reference(s),
_ => return,
};
if !valid {
let range = node_range(path, key_index);
diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaFormat",
format!(
"String at {} does not match format '{format}'",
format_path(path)
),
));
}
}
fn validate_content(
s: &str,
schema: &JsonSchema,
path: &[String],
key_index: &HashMap<String, Range>,
diagnostics: &mut Vec<Diagnostic>,
) {
let decoded_bytes: Option<Vec<u8>> = if let Some(enc) = &schema.content_encoding {
let result = match enc.as_str() {
"base64" => data_encoding::BASE64.decode(s.as_bytes()),
"base64url" => data_encoding::BASE64URL.decode(s.as_bytes()),
"base32" => data_encoding::BASE32.decode(s.as_bytes()),
"base16" => data_encoding::HEXUPPER_PERMISSIVE.decode(s.as_bytes()),
_ => return,
};
if let Ok(bytes) = result {
Some(bytes)
} else {
let range = node_range(path, key_index);
diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaContentEncoding",
format!(
"String at {} is not valid {enc} encoded data",
format_path(path)
),
));
return;
}
} else {
None
};
if let Some(media_type) = &schema.content_media_type {
if media_type == "application/json" {
let text = decoded_bytes
.as_ref()
.map_or(Some(s), |bytes| std::str::from_utf8(bytes).ok());
let valid = text.is_some_and(|t| serde_json::from_str::<serde_json::Value>(t).is_ok());
if !valid {
let range = node_range(path, key_index);
diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaContentMediaType",
format!(
"String at {} does not contain valid {media_type} content",
format_path(path)
),
));
return;
}
}
}
validate_content_schema(
s,
decoded_bytes.as_deref(),
schema,
path,
key_index,
diagnostics,
);
}
fn validate_content_schema(
raw: &str,
decoded_bytes: Option<&[u8]>,
schema: &JsonSchema,
path: &[String],
key_index: &HashMap<String, Range>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(content_schema) = &schema.content_schema else {
return;
};
let content_text = decoded_bytes
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.unwrap_or(raw);
let Ok(docs) = rlsp_yaml_parser::load(content_text) else {
let range = node_range(path, key_index);
diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaContentSchema",
format!(
"Decoded content at {} could not be parsed as YAML",
format_path(path)
),
));
return;
};
for doc in &docs {
let mut content_path = path.to_vec();
content_path.push("(content)".to_string());
let content_key_index = build_key_index(&content_text.lines().collect::<Vec<_>>());
let mut ctx = Ctx::new(diagnostics, true, &content_key_index);
validate_node(&doc.root, content_schema, &content_path, &mut ctx, 0);
}
}
fn is_valid_date_time(s: &str) -> bool {
let Some(t_pos) = s.find(['T', 't']) else {
return false;
};
let (date_part, time_and_offset) = s.split_at(t_pos);
let time_and_offset = &time_and_offset[1..]; is_valid_date(date_part) && is_valid_time(time_and_offset)
}
fn is_valid_date(s: &str) -> bool {
if s.len() != 10 {
return false;
}
if s.as_bytes().get(4) != Some(&b'-') || s.as_bytes().get(7) != Some(&b'-') {
return false;
}
let Ok(year) = s[..4].parse::<u32>() else {
return false;
};
let Ok(month) = s[5..7].parse::<u32>() else {
return false;
};
let Ok(day) = s[8..10].parse::<u32>() else {
return false;
};
if month == 0 || month > 12 || day == 0 {
return false;
}
let max_day = days_in_month(year, month);
day <= max_day
}
const fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => 0,
}
}
const fn is_leap_year(year: u32) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
fn is_valid_time(s: &str) -> bool {
let (time_part, offset_part) =
if let Some(stripped) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
(stripped, "Z")
} else {
let Some(sign_pos) = s.rfind(['+', '-']) else {
return false;
};
if sign_pos < 8 {
return false;
}
(&s[..sign_pos], &s[sign_pos..])
};
let tb = time_part.as_bytes();
if tb.len() < 8 {
return false;
}
if tb.get(2) != Some(&b':') || tb.get(5) != Some(&b':') {
return false;
}
let Ok(hour) = time_part[..2].parse::<u32>() else {
return false;
};
let Ok(minute) = time_part[3..5].parse::<u32>() else {
return false;
};
let Ok(second) = time_part[6..8].parse::<u32>() else {
return false;
};
if hour > 23 || minute > 59 || second > 60 {
return false;
}
if tb.len() > 8 {
if tb.get(8) != Some(&b'.') {
return false;
}
if time_part[9..].is_empty() || !time_part[9..].bytes().all(|b| b.is_ascii_digit()) {
return false;
}
}
if offset_part == "Z" {
return true;
}
let offset = &offset_part[1..]; if offset.len() != 5 || offset.as_bytes().get(2) != Some(&b':') {
return false;
}
let Ok(off_h) = offset[..2].parse::<u32>() else {
return false;
};
let Ok(off_m) = offset[3..5].parse::<u32>() else {
return false;
};
off_h <= 23 && off_m <= 59
}
fn is_valid_duration(s: &str) -> bool {
let Some(rest) = s.strip_prefix('P') else {
return false;
};
if rest.is_empty() {
return false;
}
if let Some(w) = rest.strip_suffix('W') {
return !w.is_empty() && w.bytes().all(|b| b.is_ascii_digit());
}
let (date_part, time_part) = rest.find('T').map_or((rest, None), |t_pos| {
(&rest[..t_pos], Some(&rest[t_pos + 1..]))
});
if !is_valid_duration_designators(date_part, &['Y', 'M', 'D']) {
return false;
}
if let Some(tp) = time_part {
if tp.is_empty() {
return false; }
if !is_valid_duration_designators(tp, &['H', 'M', 'S']) {
return false;
}
}
!date_part.is_empty() || time_part.is_some_and(|t| !t.is_empty())
}
fn is_valid_duration_designators(s: &str, designators: &[char]) -> bool {
let mut remaining = s;
let mut last_idx: Option<usize> = None;
while !remaining.is_empty() {
let digit_end = remaining
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(remaining.len());
if digit_end == 0 {
return false; }
if digit_end == remaining.len() {
return false; }
let designator = remaining.chars().nth(digit_end).unwrap_or('\0');
let Some(idx) = designators.iter().position(|&d| d == designator) else {
return false;
};
if let Some(prev) = last_idx {
if idx <= prev {
return false; }
}
last_idx = Some(idx);
remaining = &remaining[digit_end + designator.len_utf8()..];
}
true
}
fn is_valid_email(s: &str) -> bool {
let Some(at_pos) = s.rfind('@') else {
return false;
};
let local = &s[..at_pos];
let domain = &s[at_pos + 1..];
!local.is_empty()
&& !domain.is_empty()
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
}
fn is_valid_ipv4(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|p| {
!p.is_empty()
&& p.len() <= 3
&& p.bytes().all(|b| b.is_ascii_digit())
&& p.parse::<u16>().is_ok_and(|n| n <= 255)
&& (p.len() == 1 || !p.starts_with('0')) })
}
fn is_valid_ipv6(s: &str) -> bool {
let s = s.split('%').next().unwrap_or(s);
let (s, ipv4_suffix) = if let Some(last_colon) = s.rfind(':') {
let candidate = &s[last_colon + 1..];
if candidate.contains('.') {
if !is_valid_ipv4(candidate) {
return false;
}
(&s[..last_colon], true)
} else {
(s, false)
}
} else {
(s, false)
};
let has_double_colon = s.contains("::");
let parts: Vec<&str> = if has_double_colon {
s.splitn(2, "::")
.flat_map(|h| h.split(':'))
.filter(|p| !p.is_empty())
.collect()
} else {
s.split(':').collect()
};
let expected = if ipv4_suffix { 6 } else { 8 };
let max_groups = if has_double_colon {
expected - 1
} else {
expected
};
if parts.len() > max_groups {
return false;
}
if !has_double_colon && parts.len() != expected {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 4 && p.bytes().all(|b| b.is_ascii_hexdigit()))
}
fn is_valid_hostname(s: &str) -> bool {
if s.is_empty() || s.len() > 253 {
return false;
}
let s = s.strip_suffix('.').unwrap_or(s);
s.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn is_valid_uri(s: &str) -> bool {
let Some(colon) = s.find(':') else {
return false;
};
let scheme = &s[..colon];
!scheme.is_empty()
&& scheme
.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphabetic())
&& scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.')
}
fn is_valid_uri_reference(s: &str) -> bool {
if s.is_empty() {
return true; }
is_valid_uri(s)
|| s.starts_with('/')
|| s.starts_with('?')
|| s.starts_with('#')
|| s.starts_with("//")
|| !s.contains(':') }
fn is_valid_uri_template(s: &str) -> bool {
let mut depth = 0u32;
for b in s.bytes() {
match b {
b'{' => {
if depth > 0 {
return false; }
depth += 1;
}
b'}' => {
if depth == 0 {
return false; }
depth -= 1;
}
0x00..=0x1F | 0x7F => return false, _ => {}
}
}
depth == 0
}
fn is_valid_uuid(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != 36 {
return false;
}
if bytes.get(8) != Some(&b'-')
|| bytes.get(13) != Some(&b'-')
|| bytes.get(18) != Some(&b'-')
|| bytes.get(23) != Some(&b'-')
{
return false;
}
bytes.iter().enumerate().all(|(i, &b)| {
if i == 8 || i == 13 || i == 18 || i == 23 {
true } else {
b.is_ascii_hexdigit()
}
})
}
fn is_valid_regex(s: &str) -> bool {
if s.len() > MAX_PATTERN_LEN {
return false;
}
RegexBuilder::new(s)
.size_limit(REGEX_SIZE_LIMIT)
.build()
.is_ok()
}
fn is_valid_json_pointer(s: &str) -> bool {
if s.is_empty() {
return true;
}
if !s.starts_with('/') {
return false;
}
is_json_pointer_tokens_valid(s)
}
fn is_json_pointer_tokens_valid(s: &str) -> bool {
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '~' {
match chars.next() {
Some('0' | '1') => {}
_ => return false,
}
}
}
true
}
fn is_valid_relative_json_pointer(s: &str) -> bool {
let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if digit_end == 0 {
return false; }
let rest = &s[digit_end..];
if rest == "#" {
return true;
}
is_valid_json_pointer(rest)
}
fn is_valid_idn_hostname(s: &str) -> bool {
idna::domain_to_ascii_strict(s).is_ok()
}
fn is_valid_idn_email(s: &str) -> bool {
let Some(at_pos) = s.rfind('@') else {
return false;
};
let local = &s[..at_pos];
let domain = &s[at_pos + 1..];
!local.is_empty() && idna::domain_to_ascii_strict(domain).is_ok()
}
fn is_valid_iri(s: &str) -> bool {
iri_string::types::IriStr::new(s).is_ok()
}
fn is_valid_iri_reference(s: &str) -> bool {
iri_string::types::IriReferenceStr::new(s).is_ok()
}
fn validate_numeric_constraints(val: f64, schema: &JsonSchema, path: &[String], ctx: &mut Ctx<'_>) {
if let Some(minimum) = schema.minimum {
let exclusive = schema.exclusive_minimum_draft04.unwrap_or(false);
let violation = if exclusive {
val <= minimum
} else {
val < minimum
};
if violation {
let range = node_range(path, ctx.key_index);
let msg = if exclusive {
format!(
"Value at {} is below exclusive minimum {minimum}",
format_path(path),
)
} else {
format!("Value at {} is below minimum {minimum}", format_path(path),)
};
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMinimum",
msg,
));
}
}
if let Some(maximum) = schema.maximum {
let exclusive = schema.exclusive_maximum_draft04.unwrap_or(false);
let violation = if exclusive {
val >= maximum
} else {
val > maximum
};
if violation {
let range = node_range(path, ctx.key_index);
let msg = if exclusive {
format!(
"Value at {} is above exclusive maximum {maximum}",
format_path(path),
)
} else {
format!("Value at {} is above maximum {maximum}", format_path(path),)
};
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMaximum",
msg,
));
}
}
if let Some(excl_min) = schema.exclusive_minimum {
if val <= excl_min {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMinimum",
format!(
"Value at {} is below exclusive minimum {excl_min}",
format_path(path),
),
));
}
}
if let Some(excl_max) = schema.exclusive_maximum {
if val >= excl_max {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMaximum",
format!(
"Value at {} is above exclusive maximum {excl_max}",
format_path(path),
),
));
}
}
if let Some(multiple_of) = schema.multiple_of {
if multiple_of > 0.0 {
let quotient = val / multiple_of;
if (quotient - quotient.round()).abs() >= f64::EPSILON {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaMultipleOf",
format!(
"Value at {} must be a multiple of {multiple_of}",
format_path(path),
),
));
}
}
}
}
fn validate_mapping(
entries: &[(Node<Span>, Node<Span>)],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
validate_mapping_constraints(entries, schema, path, ctx);
let properties = schema.properties.as_ref();
if let Some(required) = &schema.required {
let listed: Vec<&str> = required
.iter()
.take(MAX_ENUM_DISPLAY)
.map(String::as_str)
.collect();
let props_list = if required.len() > MAX_ENUM_DISPLAY {
format!("{}, ... ({} total)", listed.join(", "), required.len())
} else {
listed.join(", ")
};
ctx.diagnostics.extend(
required
.iter()
.filter(|req_key| !entries_contains_key(entries, req_key))
.map(|req_key| {
let range = mapping_range(path, ctx.key_index);
make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaRequired",
format!(
"Object at {} is missing required property '{}'. Expected: {}.",
format_path(path),
req_key,
props_list
),
)
}),
);
}
for (k, v) in entries {
let Some(key_str) = node_key_str(k) else {
continue;
};
let is_known = properties.is_some_and(|p| p.contains_key(&key_str));
if let Some(prop_schema) = properties.and_then(|p| p.get(&key_str)) {
let mut child_path = path.to_vec();
child_path.push(key_str.clone());
validate_node(v, prop_schema, &child_path, ctx, depth + 1);
} else {
let matched_by_pattern =
validate_pattern_properties(v, &key_str, schema, path, ctx, depth);
if !is_known && !matched_by_pattern {
match &schema.additional_properties {
Some(AdditionalProperties::Denied) => {
let range = key_range(&key_str, path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaAdditionalProperty",
format!(
"Additional property '{}' is not allowed at {}",
key_str,
format_path(path)
),
));
}
Some(AdditionalProperties::Schema(extra_schema)) => {
let mut child_path = path.to_vec();
child_path.push(key_str.clone());
validate_node(v, extra_schema, &child_path, ctx, depth + 1);
}
None => {}
}
}
}
if let Some(pn_schema) = &schema.property_names {
let key_node = Node::Scalar {
value: key_str.clone(),
style: rlsp_yaml_parser::event::ScalarStyle::Plain,
anchor: None,
tag: None,
loc: rlsp_yaml_parser::pos::Span {
start: rlsp_yaml_parser::pos::Pos::ORIGIN,
end: rlsp_yaml_parser::pos::Pos::ORIGIN,
},
leading_comments: Vec::new(),
trailing_comment: None,
};
validate_node(&key_node, pn_schema, path, ctx, depth + 1);
}
}
validate_dependencies(entries, schema, path, ctx, depth);
}
fn validate_dependencies(
entries: &[(Node<Span>, Node<Span>)],
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
if let Some(dep_req) = &schema.dependent_required {
for (trigger, required_keys) in dep_req {
if entries_contains_key(entries, trigger) {
for missing in required_keys {
if !entries_contains_key(entries, missing) {
let range = mapping_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaDependentRequired",
format!(
"Property '{}' is required when '{}' is present at {}",
missing,
trigger,
format_path(path)
),
));
}
}
}
}
}
if let Some(dep_sch) = &schema.dependent_schemas {
for (trigger, dep_schema) in dep_sch {
if entries_contains_key(entries, trigger) {
let mapping_node = Node::Mapping {
entries: entries.to_vec(),
anchor: None,
tag: None,
loc: rlsp_yaml_parser::pos::Span {
start: rlsp_yaml_parser::pos::Pos::ORIGIN,
end: rlsp_yaml_parser::pos::Pos::ORIGIN,
},
leading_comments: Vec::new(),
trailing_comment: None,
};
validate_node(&mapping_node, dep_schema, path, ctx, depth + 1);
}
}
}
}
fn validate_pattern_properties(
value: &Node<Span>,
key: &str,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) -> bool {
let Some(pattern_props) = &schema.pattern_properties else {
return false;
};
let mut matched = false;
for (pattern, pat_schema) in pattern_props {
if pattern.len() > MAX_PATTERN_LEN {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaPatternLimit",
format!(
"Pattern at {} exceeds maximum length ({MAX_PATTERN_LEN} chars) and was not validated",
format_path(path),
),
));
continue;
}
if let Some(re) = get_regex(pattern) {
if re.is_match(key) {
matched = true;
let mut child_path = path.to_vec();
child_path.push(key.to_string());
validate_node(value, pat_schema, &child_path, ctx, depth + 1);
}
} else {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaPatternLimit",
format!(
"Pattern at {} could not be compiled and was not validated",
format_path(path),
),
));
}
}
matched
}
fn validate_composition(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
if let Some(all_of) = &schema.all_of {
for branch in all_of.iter().take(MAX_BRANCH_COUNT) {
validate_node(node, branch, path, ctx, depth + 1);
}
}
if let Some(any_of) = &schema.any_of {
let key_index = ctx.key_index;
let format_validation = ctx.format_validation;
let branch_count = any_of.iter().take(MAX_BRANCH_COUNT).count();
let any_passes = any_of.iter().take(MAX_BRANCH_COUNT).any(|branch| {
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, key_index);
validate_node(node, branch, path, &mut probe, depth + 1);
scratch.is_empty()
});
if !any_passes {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaType",
format!(
"Value at {} does not match any of the {branch_count} allowed schemas (anyOf)",
format_path(path)
),
));
}
}
if let Some(one_of) = &schema.one_of {
let key_index = ctx.key_index;
let format_validation = ctx.format_validation;
let total = one_of.iter().take(MAX_BRANCH_COUNT).count();
let passing = one_of
.iter()
.take(MAX_BRANCH_COUNT)
.filter(|branch| {
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, key_index);
validate_node(node, branch, path, &mut probe, depth + 1);
scratch.is_empty()
})
.count();
if passing == 0 {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaType",
format!(
"Value at {} does not match any of the {total} oneOf schemas",
format_path(path)
),
));
} else if passing > 1 {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaType",
format!(
"Value at {} matches {passing} of the {total} oneOf schemas (expected exactly 1)",
format_path(path)
),
));
}
}
if let Some(not_schema) = &schema.not {
let key_index = ctx.key_index;
let format_validation = ctx.format_validation;
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, key_index);
validate_node(node, not_schema, path, &mut probe, depth + 1);
if scratch.is_empty() {
let range = node_range(path, ctx.key_index);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaNot",
format!(
"Value at {} must not match the schema defined in 'not'",
format_path(path)
),
));
}
}
if let Some(if_schema) = &schema.if_schema {
let key_index = ctx.key_index;
let format_validation = ctx.format_validation;
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, key_index);
validate_node(node, if_schema, path, &mut probe, depth + 1);
if scratch.is_empty() {
if let Some(then_schema) = &schema.then_schema {
validate_node(node, then_schema, path, ctx, depth + 1);
}
} else if let Some(else_schema) = &schema.else_schema {
validate_node(node, else_schema, path, ctx, depth + 1);
}
}
}
fn yaml_type_name(node: &Node<Span>) -> &'static str {
use rlsp_yaml_parser::event::ScalarStyle;
match node {
Node::Scalar { value, style, .. } => {
if !matches!(style, ScalarStyle::Plain) {
return "string";
}
if scalar_helpers::is_null(value) {
"null"
} else if scalar_helpers::is_bool(value) {
"boolean"
} else if scalar_helpers::is_integer(value) {
"integer"
} else if scalar_helpers::is_float(value) {
"number"
} else {
"string"
}
}
Node::Mapping { .. } => "object",
Node::Sequence { .. } => "array",
Node::Alias { .. } => "unknown",
}
}
fn type_matches(yaml_type: &str, schema_type: &SchemaType) -> bool {
match schema_type {
SchemaType::Single(t) => single_type_matches(yaml_type, t),
SchemaType::Multiple(ts) => ts.iter().any(|t| single_type_matches(yaml_type, t)),
}
}
fn single_type_matches(yaml_type: &str, schema_type: &str) -> bool {
if yaml_type == schema_type {
return true;
}
if schema_type == "number" && yaml_type == "integer" {
return true;
}
false
}
fn display_schema_type(schema_type: &SchemaType) -> String {
match schema_type {
SchemaType::Single(t) => t.clone(),
SchemaType::Multiple(ts) => ts.join(" | "),
}
}
fn yaml_to_json(node: &Node<Span>) -> Option<serde_json::Value> {
use rlsp_yaml_parser::event::ScalarStyle;
match node {
Node::Scalar { value, style, .. } => {
if !matches!(style, ScalarStyle::Plain) {
return Some(serde_json::Value::String(value.clone()));
}
if scalar_helpers::is_null(value) {
Some(serde_json::Value::Null)
} else if scalar_helpers::is_bool(value) {
Some(serde_json::Value::Bool(matches!(
value.as_str(),
"true" | "True" | "TRUE"
)))
} else if let Some(i) = scalar_helpers::parse_integer(value) {
Some(serde_json::Value::Number(i.into()))
} else if let Some(f) = scalar_helpers::parse_float(value) {
serde_json::Number::from_f64(f).map(serde_json::Value::Number)
} else {
Some(serde_json::Value::String(value.clone()))
}
}
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => None,
}
}
fn make_diagnostic(
range: Range,
severity: DiagnosticSeverity,
code: &str,
message: String,
) -> Diagnostic {
let message = truncate_message(message);
Diagnostic {
range,
severity: Some(severity),
code: Some(NumberOrString::String(code.to_string())),
source: Some("rlsp-yaml".to_string()),
message,
..Diagnostic::default()
}
}
fn truncate_message(msg: String) -> String {
if msg.chars().count() <= MAX_DESCRIPTION_LEN {
return msg;
}
let boundary = msg
.char_indices()
.nth(MAX_DESCRIPTION_LEN)
.map_or(msg.len(), |(i, _)| i);
format!("{}…", &msg[..boundary])
}
fn node_range(path: &[String], key_index: &HashMap<String, Range>) -> Range {
path.last().map_or_else(
|| Range::new(Position::new(0, 0), Position::new(0, 0)),
|key| find_key_range(key, key_index),
)
}
fn mapping_range(path: &[String], key_index: &HashMap<String, Range>) -> Range {
path.last().map_or_else(
|| Range::new(Position::new(0, 0), Position::new(0, 0)),
|key| find_key_range(key, key_index),
)
}
fn key_range(key: &str, _path: &[String], key_index: &HashMap<String, Range>) -> Range {
find_key_range(key, key_index)
}
fn find_key_range(key: &str, key_index: &HashMap<String, Range>) -> Range {
let key = key.trim_start_matches('[').trim_end_matches(']');
key_index
.get(key)
.copied()
.unwrap_or_else(|| Range::new(Position::new(0, 0), Position::new(0, 0)))
}
fn format_path(path: &[String]) -> String {
if path.is_empty() {
return "<root>".to_string();
}
let mut result = String::new();
for segment in path {
if !segment.starts_with('[') && !result.is_empty() {
result.push('.');
}
result.push_str(segment);
}
result
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::schema::{AdditionalProperties, JsonSchema, SchemaType};
use serde_json::json;
fn parse_docs(text: &str) -> Vec<Document<Span>> {
rlsp_yaml_parser::load(text).unwrap_or_default()
}
fn string_schema() -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
}
}
fn integer_schema() -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
}
}
fn object_schema_with_props(props: Vec<(&str, JsonSchema)>) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some(props.into_iter().map(|(k, v)| (k.to_string(), v)).collect()),
..JsonSchema::default()
}
}
fn code_of(d: &Diagnostic) -> &str {
match &d.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => "",
}
}
#[test]
fn should_produce_no_diagnostics_when_required_property_present() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
properties: Some([("name".to_string(), string_schema())].into()),
..JsonSchema::default()
};
let docs = parse_docs("name: Alice");
let result = validate_schema("name: Alice", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_for_missing_required_property() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaRequired");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(result[0].message.contains("name"));
}
#[test]
fn should_produce_one_diagnostic_per_missing_required_property() {
let schema = JsonSchema {
required: Some(vec![
"name".to_string(),
"age".to_string(),
"email".to_string(),
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert_eq!(result.len(), 3);
assert!(result.iter().all(|d| code_of(d) == "schemaRequired"));
}
#[test]
fn should_produce_no_diagnostics_when_all_required_present() {
let schema = JsonSchema {
required: Some(vec!["a".to_string(), "b".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("a: 1\nb: 2");
let result = validate_schema("a: 1\nb: 2", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_for_empty_required_array() {
let schema = JsonSchema {
required: Some(vec![]),
..JsonSchema::default()
};
let docs = parse_docs("key: value");
let result = validate_schema("key: value", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_validate_required_in_nested_mapping() {
let spec_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
required: Some(vec!["name".to_string()]),
properties: Some([("name".to_string(), string_schema())].into()),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let text = "spec:\n other: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaRequired");
assert!(result[0].message.contains("name"));
assert!(result[0].message.contains("spec"));
}
#[test]
fn should_produce_no_diagnostics_when_type_matches_string() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let docs = parse_docs("name: Alice");
let result = validate_schema("name: Alice", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_for_string_where_integer_expected() {
let schema = object_schema_with_props(vec![("count", integer_schema())]);
let docs = parse_docs("count: \"hello\"");
let result = validate_schema("count: \"hello\"", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(result[0].message.contains("integer"));
}
#[test]
fn should_produce_error_for_integer_where_string_expected() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let docs = parse_docs("name: 42");
let result = validate_schema("name: 42", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_error_for_boolean_where_string_expected() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let docs = parse_docs("name: true");
let result = validate_schema("name: true", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_error_for_mapping_where_string_expected() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let text = "name:\n nested: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_error_for_sequence_where_object_expected() {
let config_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("config", config_schema)]);
let text = "config:\n - item";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_error_for_null_where_string_expected() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let docs = parse_docs("name: ~");
let result = validate_schema("name: ~", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_for_null_when_null_in_type_array() {
let schema = object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Multiple(vec![
"string".to_string(),
"null".to_string(),
])),
..JsonSchema::default()
},
)]);
let docs = parse_docs("name: ~");
let result = validate_schema("name: ~", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_no_type_specified() {
let schema = object_schema_with_props(vec![("name", JsonSchema::default())]);
let docs = parse_docs("name: 42");
let result = validate_schema("name: 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_for_integer_type_with_integer_value() {
let schema = object_schema_with_props(vec![("port", integer_schema())]);
let docs = parse_docs("port: 8080");
let result = validate_schema("port: 8080", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_for_boolean_type_with_boolean_value() {
let schema = object_schema_with_props(vec![(
"enabled",
JsonSchema {
schema_type: Some(SchemaType::Single("boolean".to_string())),
..JsonSchema::default()
},
)]);
let docs = parse_docs("enabled: true");
let result = validate_schema("enabled: true", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_for_array_type_with_sequence_value() {
let schema = object_schema_with_props(vec![(
"items",
JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
..JsonSchema::default()
},
)]);
let text = "items:\n - one\n - two";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_value_matches_enum() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
enum_values: Some(vec![json!("prod"), json!("staging"), json!("dev")]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: staging");
let result = validate_schema("env: staging", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_not_in_enum() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging"), json!("dev")]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: testing");
let result = validate_schema("env: testing", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaEnum");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(
result[0].message.contains("prod"),
"message should contain 'prod'"
);
assert!(
result[0].message.contains("staging"),
"message should contain 'staging'"
);
assert!(
result[0].message.contains("dev"),
"message should contain 'dev'"
);
}
#[test]
fn should_produce_no_diagnostics_for_enum_with_integer_match() {
let schema = object_schema_with_props(vec![(
"level",
JsonSchema {
enum_values: Some(vec![json!(1), json!(2), json!(3)]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("level: 2");
let result = validate_schema("level: 2", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_for_enum_with_integer_mismatch() {
let schema = object_schema_with_props(vec![(
"level",
JsonSchema {
enum_values: Some(vec![json!(1), json!(2), json!(3)]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("level: 5");
let result = validate_schema("level: 5", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaEnum");
}
#[test]
fn should_handle_enum_with_mixed_types() {
let schema = object_schema_with_props(vec![(
"value",
JsonSchema {
enum_values: Some(vec![json!("auto"), json!(0), serde_json::Value::Null]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("value: auto");
let result = validate_schema("value: auto", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_additional_properties_absent() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let text = "name: Alice\nextra: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_warning_for_extra_key_when_additional_properties_false() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let text = "name: Alice\nextra: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaAdditionalProperty");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(result[0].message.contains("extra"));
}
#[test]
fn should_produce_no_diagnostics_for_known_keys_when_additional_properties_false() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: Alice");
let result = validate_schema("name: Alice", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_one_warning_per_extra_key() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let text = "name: Alice\nextra1: a\nextra2: b";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 2);
assert!(
result
.iter()
.all(|d| code_of(d) == "schemaAdditionalProperty")
);
}
#[test]
fn should_validate_extra_properties_against_additional_properties_schema() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Schema(Box::new(integer_schema()))),
..JsonSchema::default()
};
let text = "name: Alice\nextra: not-an-int";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_when_all_of_all_pass() {
let schema = JsonSchema {
all_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("a: 1\nb: 2");
let result = validate_schema("a: 1\nb: 2", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostics_when_any_all_of_fails() {
let schema = JsonSchema {
all_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("a: 1");
let result = validate_schema("a: 1", &docs, &schema, true);
assert!(!result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_any_of_one_passes() {
let schema = JsonSchema {
any_of: Some(vec![
object_schema_with_props(vec![("name", string_schema())]),
object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
},
)]),
]),
..JsonSchema::default()
};
let docs = parse_docs("name: Alice");
let result = validate_schema("name: Alice", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_none_of_any_of_pass() {
let schema = JsonSchema {
any_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_exactly_one_of_passes() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("a: 1");
let result = validate_schema("a: 1", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_zero_of_one_of_pass() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_multiple_of_one_of_pass() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
object_schema_with_props(vec![("a", string_schema())]),
]),
..JsonSchema::default()
};
let docs = parse_docs("a: hello");
let result = validate_schema("a: hello", &docs, &schema, true);
assert!(!result.is_empty());
}
#[test]
fn should_validate_properties_recursively() {
let server_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("port".to_string(), integer_schema())].into()),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("server", server_schema)]);
let text = "server:\n port: not-an-int";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_validate_array_items_against_items_schema() {
let schema = object_schema_with_props(vec![(
"ports",
JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
items: Some(Box::new(integer_schema())),
..JsonSchema::default()
},
)]);
let text = "ports:\n - 8080\n - not-an-int";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_for_valid_array_items() {
let schema = object_schema_with_props(vec![(
"ports",
JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
items: Some(Box::new(integer_schema())),
..JsonSchema::default()
},
)]);
let text = "ports:\n - 8080\n - 9090";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_validate_deeply_nested_schema_five_levels() {
let leaf = string_schema();
let l4 = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("d".to_string(), leaf)].into()),
..JsonSchema::default()
};
let l3 = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("c".to_string(), l4)].into()),
..JsonSchema::default()
};
let l2 = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("b".to_string(), l3)].into()),
..JsonSchema::default()
};
let l1 = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("a".to_string(), l2)].into()),
..JsonSchema::default()
};
let text = "a:\n b:\n c:\n d: hello";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &l1, true);
assert!(result.is_empty());
}
#[test]
fn should_not_stack_overflow_on_deep_nesting() {
let mut schema = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
};
for _ in 0..25 {
schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("x".to_string(), schema)].into()),
..JsonSchema::default()
};
}
let text = "x:\n".repeat(25) + " value: leaf";
let docs = parse_docs(&text);
let _ = validate_schema(&text, &docs, &schema, true);
}
#[test]
fn should_set_source_to_rlsp_yaml() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert!(!result.is_empty());
assert!(
result
.iter()
.all(|d| d.source == Some("rlsp-yaml".to_string()))
);
}
#[test]
fn should_set_correct_code_for_required_violation() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(
result[0].code,
Some(NumberOrString::String("schemaRequired".to_string()))
);
}
#[test]
fn should_set_correct_code_for_type_violation() {
let schema = object_schema_with_props(vec![("count", integer_schema())]);
let docs = parse_docs("count: hello");
let result = validate_schema("count: hello", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(
result[0].code,
Some(NumberOrString::String("schemaType".to_string()))
);
}
#[test]
fn should_set_correct_code_for_enum_violation() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging")]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: testing");
let result = validate_schema("env: testing", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(
result[0].code,
Some(NumberOrString::String("schemaEnum".to_string()))
);
}
#[test]
fn should_set_correct_code_for_additional_property_violation() {
let schema = JsonSchema {
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: Alice\nextra: value");
let result = validate_schema("name: Alice\nextra: value", &docs, &schema, true);
let ap_diags: Vec<_> = result
.iter()
.filter(|d| code_of(d) == "schemaAdditionalProperty")
.collect();
assert!(!ap_diags.is_empty());
assert_eq!(
ap_diags[0].code,
Some(NumberOrString::String(
"schemaAdditionalProperty".to_string()
))
);
}
#[test]
fn should_set_error_severity_for_required_violation() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_set_error_severity_for_type_violation() {
let schema = object_schema_with_props(vec![("count", integer_schema())]);
let docs = parse_docs("count: hello");
let result = validate_schema("count: hello", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_set_error_severity_for_enum_violation() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
enum_values: Some(vec![json!("prod")]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: testing");
let result = validate_schema("env: testing", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_set_warning_severity_for_additional_property_violation() {
let schema = JsonSchema {
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: Alice\nextra: value");
let result = validate_schema("name: Alice\nextra: value", &docs, &schema, true);
let ap = result
.iter()
.find(|d| code_of(d) == "schemaAdditionalProperty")
.expect("should have additionalProperty diagnostic");
assert_eq!(ap.severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn should_include_property_path_in_required_diagnostic_message() {
let spec_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let text = "spec:\n other: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(!result.is_empty());
let msg = &result[0].message;
assert!(
msg.contains("spec"),
"message should reference parent path 'spec', got: {msg}"
);
}
#[test]
fn should_include_valid_values_in_enum_diagnostic_message() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging")]),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: testing");
let result = validate_schema("env: testing", &docs, &schema, true);
assert!(!result.is_empty());
let msg = &result[0].message;
assert!(msg.contains("prod"), "message should contain 'prod'");
assert!(msg.contains("staging"), "message should contain 'staging'");
}
#[test]
fn should_return_empty_for_empty_yaml_document() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("");
let result = validate_schema("", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_return_empty_when_docs_is_empty() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let result = validate_schema("name: Alice", &[], &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_return_empty_for_schema_with_no_constraints() {
let schema = JsonSchema::default();
let docs = parse_docs("anything: value\nnested:\n key: 123");
let result = validate_schema("anything: value\nnested:\n key: 123", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_return_empty_for_yaml_with_parse_errors() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let result = validate_schema("invalid: [yaml", &[], &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_validate_each_document_in_multi_document_yaml() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let text = "name: Alice\n---\nage: 30";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(
result
.iter()
.filter(|d| code_of(d) == "schemaRequired")
.count(),
1
);
}
#[test]
fn should_produce_no_diagnostics_for_unknown_property_when_no_properties_in_schema() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
};
let docs = parse_docs("anything: value");
let result = validate_schema("anything: value", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_complete_without_panic_for_deeply_nested_yaml_and_schema() {
let mut schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
};
for _ in 0..100 {
schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("child".to_string(), schema)].into()),
..JsonSchema::default()
};
}
let mut text = String::new();
for i in 0..100 {
for _ in 0..i {
text.push_str(" ");
}
text.push_str("child:\n");
}
let docs = parse_docs(&text);
let _result = validate_schema(&text, &docs, &schema, true);
}
#[test]
fn should_not_recurse_past_depth_limit() {
let mut schema = string_schema();
for _ in 0..70 {
schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("child".to_string(), schema)].into()),
..JsonSchema::default()
};
}
let mut text = String::new();
for i in 0..70 {
for _ in 0..i {
text.push_str(" ");
}
text.push_str("child:\n");
}
for _ in 0..70 {
text.push_str(" ");
}
text.push_str("42\n");
let docs = parse_docs(&text);
let _result = validate_schema(&text, &docs, &schema, true);
}
#[test]
fn should_complete_in_bounded_time_for_one_of_with_many_alternatives() {
let branches: Vec<JsonSchema> = (0..50)
.map(|i| JsonSchema {
required: Some(vec![format!("field_{i}")]),
..JsonSchema::default()
})
.collect();
let schema = JsonSchema {
one_of: Some(branches),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let _result = validate_schema("other: value", &docs, &schema, true);
}
#[test]
fn should_complete_for_all_of_with_many_branches() {
let branches: Vec<JsonSchema> = (0..25)
.map(|i| JsonSchema {
required: Some(vec![format!("field_{i}")]),
..JsonSchema::default()
})
.collect();
let schema = JsonSchema {
all_of: Some(branches),
..JsonSchema::default()
};
let docs = parse_docs("field_0: value");
let result = validate_schema("field_0: value", &docs, &schema, true);
assert!(!result.is_empty());
}
#[test]
fn should_truncate_long_description_in_diagnostic_message() {
let long_desc = "x".repeat(1000);
let prop_schema = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
description: Some(long_desc),
..JsonSchema::default()
};
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
properties: Some([("name".to_string(), prop_schema)].into()),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert!(!result.is_empty());
for d in &result {
assert!(
d.message.len() <= 300,
"diagnostic message too long: {} chars",
d.message.len()
);
}
}
#[test]
fn should_truncate_long_enum_value_list_in_diagnostic_message() {
let enum_values: Vec<serde_json::Value> =
(0..50).map(|i| json!(format!("opt{i}"))).collect();
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
enum_values: Some(enum_values),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: invalid");
let result = validate_schema("env: invalid", &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(code_of(&result[0]), "schemaEnum");
assert!(
result[0].message.len() <= 500,
"enum diagnostic message too long: {} chars",
result[0].message.len()
);
}
#[test]
fn should_continue_without_schema_validation_when_cache_lock_poisoned() {
use std::sync::{Arc, Mutex};
let lock: Arc<Mutex<()>> = Arc::new(Mutex::new(()));
let lock_clone = Arc::clone(&lock);
let handle = std::thread::spawn(move || {
let _guard = lock_clone.lock().unwrap();
panic!("intentional panic to poison the mutex");
});
assert!(handle.join().is_err(), "thread should have panicked");
assert!(
lock.lock().is_err(),
"poisoned mutex must return Err from lock()"
);
assert!(
lock.lock().ok().is_none(),
".ok() on poisoned lock must return None"
);
}
#[test]
fn should_include_expected_properties_in_required_diagnostic_message() {
let schema = JsonSchema {
required: Some(vec![
"name".to_string(),
"age".to_string(),
"email".to_string(),
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let msg = &result[0].message;
assert!(
msg.contains("Expected:"),
"message should contain 'Expected:', got: {msg}"
);
assert!(
msg.contains("name"),
"message should contain 'name', got: {msg}"
);
assert!(
msg.contains("age"),
"message should contain 'age', got: {msg}"
);
assert!(
msg.contains("email"),
"message should contain 'email', got: {msg}"
);
}
#[test]
fn should_truncate_expected_properties_list_when_more_than_max() {
let schema = JsonSchema {
required: Some(vec![
"alpha".to_string(),
"beta".to_string(),
"gamma".to_string(),
"delta".to_string(),
"epsilon".to_string(),
"zeta".to_string(),
"eta".to_string(),
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let msg = &result[0].message;
assert!(
msg.contains("(7 total)"),
"message should contain total count, got: {msg}"
);
assert!(
msg.contains("..."),
"message should contain ellipsis for truncation, got: {msg}"
);
}
#[test]
fn should_produce_no_diagnostics_when_string_matches_pattern() {
let schema = object_schema_with_props(vec![(
"code",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
pattern: Some("^[A-Z]{3}$".to_string()),
..JsonSchema::default()
},
)]);
let docs = parse_docs("code: ABC");
let result = validate_schema("code: ABC", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_string_does_not_match_pattern() {
let schema = object_schema_with_props(vec![(
"code",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
pattern: Some("^[A-Z]{3}$".to_string()),
..JsonSchema::default()
},
)]);
let docs = parse_docs("code: abc");
let result = validate_schema("code: abc", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPattern");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_emit_warning_when_pattern_exceeds_max_length() {
let long_pattern = "a".repeat(1025);
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
pattern: Some(long_pattern),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: anything");
let result = validate_schema("val: anything", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPatternLimit");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn should_emit_warning_when_pattern_cannot_be_compiled() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
pattern: Some("[invalid".to_string()),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: anything");
let result = validate_schema("val: anything", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPatternLimit");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn should_produce_no_diagnostics_when_string_meets_min_length() {
let schema = object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(3),
..JsonSchema::default()
},
)]);
let docs = parse_docs("name: abc");
let result = validate_schema("name: abc", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_string_is_shorter_than_min_length() {
let schema = object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(5),
..JsonSchema::default()
},
)]);
let docs = parse_docs("name: hi");
let result = validate_schema("name: hi", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinLength");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_string_meets_max_length() {
let schema = object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
max_length: Some(10),
..JsonSchema::default()
},
)]);
let docs = parse_docs("name: hello");
let result = validate_schema("name: hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_string_exceeds_max_length() {
let schema = object_schema_with_props(vec![(
"name",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
max_length: Some(3),
..JsonSchema::default()
},
)]);
let docs = parse_docs("name: toolong");
let result = validate_schema("name: toolong", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaxLength");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_integer_meets_minimum() {
let schema = object_schema_with_props(vec![(
"port",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
minimum: Some(1.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("port: 80");
let result = validate_schema("port: 80", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_integer_is_below_minimum() {
let schema = object_schema_with_props(vec![(
"port",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
minimum: Some(1.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("port: 0");
let result = validate_schema("port: 0", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinimum");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_integer_meets_maximum() {
let schema = object_schema_with_props(vec![(
"port",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
maximum: Some(65535.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("port: 8080");
let result = validate_schema("port: 8080", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_integer_exceeds_maximum() {
let schema = object_schema_with_props(vec![(
"port",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
maximum: Some(65535.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("port: 99999");
let result = validate_schema("port: 99999", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaximum");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_error_when_value_equals_minimum_and_exclusive_draft04() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(true),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinimum");
}
#[test]
fn should_produce_no_diagnostics_when_value_equals_minimum_and_not_exclusive_draft04() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(false),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_equals_maximum_and_exclusive_draft04() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(true),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 10");
let result = validate_schema("val: 10", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaximum");
}
#[test]
fn should_produce_error_when_value_equals_exclusive_minimum_draft06() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinimum");
}
#[test]
fn should_produce_no_diagnostics_when_value_exceeds_exclusive_minimum_draft06() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 6");
let result = validate_schema("val: 6", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_equals_exclusive_maximum_draft06() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 10");
let result = validate_schema("val: 10", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaximum");
}
#[test]
fn should_produce_no_diagnostics_when_value_is_below_exclusive_maximum_draft06() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 9");
let result = validate_schema("val: 9", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_value_is_multiple_of() {
let schema = object_schema_with_props(vec![(
"count",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
multiple_of: Some(5.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("count: 15");
let result = validate_schema("count: 15", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_is_not_multiple_of() {
let schema = object_schema_with_props(vec![(
"count",
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
multiple_of: Some(5.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("count: 7");
let result = validate_schema("count: 7", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMultipleOf");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_value_equals_const() {
let schema = object_schema_with_props(vec![(
"version",
JsonSchema {
const_value: Some(json!("v1")),
..JsonSchema::default()
},
)]);
let docs = parse_docs("version: v1");
let result = validate_schema("version: v1", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_does_not_equal_const() {
let schema = object_schema_with_props(vec![(
"version",
JsonSchema {
const_value: Some(json!("v1")),
..JsonSchema::default()
},
)]);
let docs = parse_docs("version: v2");
let result = validate_schema("version: v2", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaConst");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_integer_equals_const() {
let schema = object_schema_with_props(vec![(
"level",
JsonSchema {
const_value: Some(json!(42)),
..JsonSchema::default()
},
)]);
let docs = parse_docs("level: 42");
let result = validate_schema("level: 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_skip_const_check_for_mapping_node() {
let schema = object_schema_with_props(vec![(
"obj",
JsonSchema {
const_value: Some(json!({"key": "val"})),
..JsonSchema::default()
},
)]);
let text = "obj:\n key: other";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.iter().all(|d| code_of(d) != "schemaConst"));
}
#[test]
fn should_produce_error_when_value_matches_not_schema() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
not: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hello");
let result = validate_schema("val: hello", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaNot");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_value_does_not_match_not_schema() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
not: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 42");
let result = validate_schema("val: 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_reject_string_when_not_type_string() {
let schema = JsonSchema {
not: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let docs = parse_docs("hello");
let result = validate_schema("hello", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaNot");
}
#[test]
fn should_allow_integer_when_not_type_string() {
let schema = JsonSchema {
not: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let docs = parse_docs("42");
let result = validate_schema("42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_value_matches_not_enum() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
not: Some(Box::new(JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging")]),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: prod");
let result = validate_schema("env: prod", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaNot");
}
#[test]
fn should_produce_no_diagnostics_when_value_outside_not_enum() {
let schema = object_schema_with_props(vec![(
"env",
JsonSchema {
not: Some(Box::new(JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging")]),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("env: dev");
let result = validate_schema("env: dev", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_validate_value_against_pattern_properties_schema() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("^str_".to_string(), string_schema())]),
..JsonSchema::default()
};
let text = "str_name: 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_when_pattern_property_value_is_valid() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("^str_".to_string(), string_schema())]),
..JsonSchema::default()
};
let text = "str_name: hello";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_not_trigger_additional_properties_for_key_matched_by_pattern() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("^str_".to_string(), string_schema())]),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let text = "str_name: hello";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
result
.iter()
.all(|d| code_of(d) != "schemaAdditionalProperty")
);
}
#[test]
fn should_trigger_additional_properties_for_key_not_matched_by_pattern() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("^str_".to_string(), string_schema())]),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let text = "other: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaAdditionalProperty");
}
#[test]
fn should_prefer_properties_over_pattern_properties_for_known_key() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), integer_schema())].into()),
pattern_properties: Some(vec![("^name$".to_string(), string_schema())]),
..JsonSchema::default()
};
let text = "name: 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_match_key_against_multiple_patterns_and_validate_all() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![
("^x_".to_string(), string_schema()),
("num".to_string(), integer_schema()),
]),
..JsonSchema::default()
};
let text = "x_num: 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(
result.iter().filter(|d| code_of(d) == "schemaType").count(),
1
);
}
#[test]
fn should_emit_warning_for_over_length_pattern_and_fall_through_to_additional_properties() {
let long_pattern = "a".repeat(1025);
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![(long_pattern, string_schema())]),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let text = "key: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.iter().any(|d| code_of(d) == "schemaPatternLimit"
&& d.severity == Some(DiagnosticSeverity::WARNING)));
assert!(
result
.iter()
.any(|d| code_of(d) == "schemaAdditionalProperty")
);
}
fn array_schema(min: Option<u64>, max: Option<u64>, unique: Option<bool>) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
min_items: min,
max_items: max,
unique_items: unique,
..JsonSchema::default()
}
}
#[test]
fn should_produce_error_when_array_has_fewer_items_than_min_items() {
let schema = object_schema_with_props(vec![("tags", array_schema(Some(2), None, None))]);
let text = "tags:\n - a";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinItems");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_array_meets_min_items() {
let schema = object_schema_with_props(vec![("tags", array_schema(Some(2), None, None))]);
let text = "tags:\n - a\n - b";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_array_exceeds_max_items() {
let schema = object_schema_with_props(vec![("tags", array_schema(None, Some(2), None))]);
let text = "tags:\n - a\n - b\n - c";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaxItems");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_array_meets_max_items() {
let schema = object_schema_with_props(vec![("tags", array_schema(None, Some(2), None))]);
let text = "tags:\n - a\n - b";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_array_has_duplicate_items_and_unique_items_true() {
let schema = object_schema_with_props(vec![("tags", array_schema(None, None, Some(true)))]);
let text = "tags:\n - foo\n - bar\n - foo";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaUniqueItems");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_all_items_unique_and_unique_items_true() {
let schema = object_schema_with_props(vec![("tags", array_schema(None, None, Some(true)))]);
let text = "tags:\n - foo\n - bar\n - baz";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_unique_items_false_even_with_duplicates() {
let schema =
object_schema_with_props(vec![("tags", array_schema(None, None, Some(false)))]);
let text = "tags:\n - foo\n - foo";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_emit_warning_when_pattern_limit_exceeded_in_pattern_properties() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("[invalid".to_string(), string_schema())]),
..JsonSchema::default()
};
let text = "key: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.iter().any(|d| code_of(d) == "schemaPatternLimit"
&& d.severity == Some(DiagnosticSeverity::WARNING)));
}
#[test]
fn should_still_match_valid_string_against_pattern_after_hardening() {
let schema = object_schema_with_props(vec![(
"code",
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
pattern: Some("^[A-Z]{3}$".to_string()),
..JsonSchema::default()
},
)]);
let docs = parse_docs("code: abc");
let result = validate_schema("code: abc", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPattern");
}
#[test]
fn should_still_match_valid_pattern_property_after_hardening() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
pattern_properties: Some(vec![("^str_".to_string(), string_schema())]),
..JsonSchema::default()
};
let text = "str_name: 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_when_all_keys_match_property_names_pattern() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
property_names: Some(Box::new(JsonSchema {
pattern: Some("^[a-z_]+$".to_string()),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "foo: 1\nbar_baz: 2";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_key_violates_property_names_pattern() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
property_names: Some(Box::new(JsonSchema {
pattern: Some("^[a-z_]+$".to_string()),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "BadKey: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPattern");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_diagnostic_when_key_violates_property_names_min_length() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
property_names: Some(Box::new(JsonSchema {
min_length: Some(3),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "ab: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinLength");
}
#[test]
fn should_produce_diagnostic_when_key_not_in_property_names_enum() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
property_names: Some(Box::new(JsonSchema {
enum_values: Some(vec![json!("foo"), json!("bar")]),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "baz: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaEnum");
}
#[test]
fn should_apply_property_names_to_all_keys_regardless_of_properties() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("name".to_string(), string_schema())].into()),
property_names: Some(Box::new(JsonSchema {
pattern: Some("^[a-z]+$".to_string()),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "name: Alice\nextra: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostics_for_all_violating_keys_with_property_names() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
property_names: Some(Box::new(JsonSchema {
pattern: Some("^[a-z]+$".to_string()),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let text = "UPPER: 1\nAlso_Bad: 2\ngood: 3";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(
result
.iter()
.filter(|d| code_of(d) == "schemaPattern")
.count(),
2
);
}
#[test]
fn should_produce_error_when_trigger_present_and_dependent_required_missing() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_required: Some(
[(
"credit_card".to_string(),
vec!["billing_address".to_string()],
)]
.into(),
),
..JsonSchema::default()
};
let text = "credit_card: 1234";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaDependentRequired");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(result[0].message.contains("billing_address"));
assert!(result[0].message.contains("credit_card"));
}
#[test]
fn should_produce_no_diagnostics_when_trigger_and_dependency_both_present() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_required: Some(
[(
"credit_card".to_string(),
vec!["billing_address".to_string()],
)]
.into(),
),
..JsonSchema::default()
};
let text = "credit_card: 1234\nbilling_address: 123 Main St";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_trigger_absent_in_dependent_required() {
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_required: Some(
[(
"credit_card".to_string(),
vec!["billing_address".to_string()],
)]
.into(),
),
..JsonSchema::default()
};
let text = "name: Alice";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_trigger_present_and_dependent_schema_fails() {
let dep_schema = JsonSchema {
required: Some(vec!["age".to_string()]),
..JsonSchema::default()
};
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_schemas: Some([("name".to_string(), dep_schema)].into()),
..JsonSchema::default()
};
let text = "name: Alice";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(!result.is_empty());
assert_eq!(code_of(&result[0]), "schemaRequired");
}
#[test]
fn should_produce_no_diagnostics_when_dependent_schema_passes() {
let dep_schema = JsonSchema {
required: Some(vec!["age".to_string()]),
..JsonSchema::default()
};
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_schemas: Some([("name".to_string(), dep_schema)].into()),
..JsonSchema::default()
};
let text = "name: Alice\nage: 30";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_dependent_schema_trigger_absent() {
let dep_schema = JsonSchema {
required: Some(vec!["age".to_string()]),
..JsonSchema::default()
};
let schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
dependent_schemas: Some([("name".to_string(), dep_schema)].into()),
..JsonSchema::default()
};
let text = "other: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_apply_then_and_pass_when_if_matches_and_then_passes() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
then_schema: Some(Box::new(JsonSchema {
min_length: Some(3),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hello");
let result = validate_schema("val: hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_apply_then_and_fail_when_if_matches_and_then_fails() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
then_schema: Some(Box::new(JsonSchema {
min_length: Some(10),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hi");
let result = validate_schema("val: hi", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinLength");
}
#[test]
fn should_apply_else_and_pass_when_if_does_not_match_and_else_passes() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
else_schema: Some(Box::new(JsonSchema {
minimum: Some(0.0),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_apply_else_and_fail_when_if_does_not_match_and_else_fails() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
else_schema: Some(Box::new(JsonSchema {
minimum: Some(10.0),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 3");
let result = validate_schema("val: 3", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinimum");
}
#[test]
fn should_produce_no_diagnostics_when_if_matches_but_no_then() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
else_schema: Some(Box::new(JsonSchema {
minimum: Some(0.0),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hello");
let result = validate_schema("val: hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_if_does_not_match_and_no_else() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
if_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
then_schema: Some(Box::new(JsonSchema {
min_length: Some(10),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 42");
let result = validate_schema("val: 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_ignore_then_and_else_when_no_if() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
then_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
})),
else_schema: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hello");
let result = validate_schema("val: hello", &docs, &schema, true);
assert!(result.is_empty());
}
fn contains_schema(min_contains: Option<u64>, max_contains: Option<u64>) -> JsonSchema {
JsonSchema {
contains: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
})),
min_contains,
max_contains,
..JsonSchema::default()
}
}
#[test]
fn should_produce_no_diagnostics_when_array_has_one_matching_item_no_min_max() {
let schema = object_schema_with_props(vec![("items", contains_schema(None, None))]);
let docs = parse_docs("items:\n - 1\n - hello");
let result = validate_schema("items:\n - 1\n - hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_no_items_match_contains_schema() {
let schema = object_schema_with_props(vec![("items", contains_schema(None, None))]);
let docs = parse_docs("items:\n - hello\n - world");
let result = validate_schema("items:\n - hello\n - world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at least 1"));
}
#[test]
fn should_produce_diagnostic_when_min_contains_not_met() {
let schema = object_schema_with_props(vec![("items", contains_schema(Some(2), None))]);
let docs = parse_docs("items:\n - 1\n - hello");
let result = validate_schema("items:\n - 1\n - hello", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at least 2"));
}
#[test]
fn should_produce_no_diagnostics_when_min_contains_met() {
let schema = object_schema_with_props(vec![("items", contains_schema(Some(2), None))]);
let docs = parse_docs("items:\n - 1\n - 2");
let result = validate_schema("items:\n - 1\n - 2", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_max_contains_exceeded() {
let schema = object_schema_with_props(vec![("items", contains_schema(None, Some(1)))]);
let docs = parse_docs("items:\n - 1\n - 2");
let result = validate_schema("items:\n - 1\n - 2", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at most 1"));
}
#[test]
fn should_produce_no_diagnostics_when_max_contains_not_exceeded() {
let schema = object_schema_with_props(vec![("items", contains_schema(None, Some(1)))]);
let docs = parse_docs("items:\n - 1\n - hello");
let result = validate_schema("items:\n - 1\n - hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_min_contains_zero() {
let schema = object_schema_with_props(vec![("items", contains_schema(Some(0), None))]);
let docs = parse_docs("items:\n - hello\n - world");
let result = validate_schema("items:\n - hello\n - world", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_ignore_min_contains_and_max_contains_when_contains_absent() {
let schema = object_schema_with_props(vec![(
"items",
JsonSchema {
min_contains: Some(5),
max_contains: Some(0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("items:\n - hello\n - world");
let result = validate_schema("items:\n - hello\n - world", &docs, &schema, true);
assert!(result.is_empty());
}
fn tuple_schema(prefix: Vec<JsonSchema>, items: Option<JsonSchema>) -> JsonSchema {
JsonSchema {
prefix_items: Some(prefix),
items: items.map(Box::new),
..JsonSchema::default()
}
}
#[test]
fn should_produce_diagnostic_when_second_item_fails_prefix_schema() {
let schema = object_schema_with_props(vec![(
"arr",
tuple_schema(vec![string_schema(), integer_schema()], None),
)]);
let docs = parse_docs("arr:\n - hello\n - world");
let result = validate_schema("arr:\n - hello\n - world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("integer"));
}
#[test]
fn should_produce_no_diagnostics_when_all_items_match_prefix_schemas() {
let schema = object_schema_with_props(vec![(
"arr",
tuple_schema(vec![string_schema(), integer_schema()], None),
)]);
let docs = parse_docs("arr:\n - hello\n - 42");
let result = validate_schema("arr:\n - hello\n - 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_validate_extra_items_against_items_schema_when_prefix_items_set() {
let schema = object_schema_with_props(vec![(
"arr",
tuple_schema(vec![string_schema()], Some(integer_schema())),
)]);
let docs = parse_docs("arr:\n - hello\n - 42");
let result = validate_schema("arr:\n - hello\n - 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_when_extra_item_fails_items_schema() {
let schema = object_schema_with_props(vec![(
"arr",
tuple_schema(vec![string_schema()], Some(integer_schema())),
)]);
let docs = parse_docs("arr:\n - hello\n - world");
let result = validate_schema("arr:\n - hello\n - world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("integer"));
}
#[test]
fn should_produce_no_diagnostics_when_array_shorter_than_prefix_items() {
let schema = object_schema_with_props(vec![(
"arr",
tuple_schema(
vec![string_schema(), integer_schema(), string_schema()],
None,
),
)]);
let docs = parse_docs("arr:\n - hello");
let result = validate_schema("arr:\n - hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_parse_draft04_array_items_as_prefix_items() {
use crate::schema::parse_schema;
use serde_json::json;
let raw = json!({
"type": "object",
"properties": {
"arr": {
"type": "array",
"items": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
});
let schema = parse_schema(&raw).expect("valid schema");
let arr_schema = schema
.properties
.as_ref()
.and_then(|p| p.get("arr"))
.expect("arr property");
assert!(arr_schema.prefix_items.is_some());
assert_eq!(arr_schema.prefix_items.as_ref().unwrap().len(), 2);
assert!(arr_schema.items.is_none());
}
#[test]
fn should_prefer_prefix_items_over_draft04_array_items() {
use crate::schema::parse_schema;
use serde_json::json;
let raw = json!({
"prefixItems": [{ "type": "string" }],
"items": [{ "type": "integer" }, { "type": "boolean" }]
});
let schema = parse_schema(&raw).expect("valid schema");
assert!(schema.prefix_items.is_some());
assert_eq!(schema.prefix_items.as_ref().unwrap().len(), 1);
}
#[test]
fn should_produce_no_diagnostics_when_allof_evaluates_all_properties() {
let schema = JsonSchema {
all_of: Some(vec![object_schema_with_props(vec![(
"name",
string_schema(),
)])]),
unevaluated_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: hello");
let result = validate_schema("name: hello", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_for_unevaluated_property() {
let schema = JsonSchema {
properties: Some(
vec![("name".to_string(), string_schema())]
.into_iter()
.collect(),
),
unevaluated_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: hello\nextra: world");
let result = validate_schema("name: hello\nextra: world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("extra"));
}
#[test]
fn should_validate_unevaluated_property_against_schema() {
let schema = JsonSchema {
properties: Some(
vec![("name".to_string(), string_schema())]
.into_iter()
.collect(),
),
unevaluated_properties: Some(AdditionalProperties::Schema(Box::new(integer_schema()))),
..JsonSchema::default()
};
let docs = parse_docs("name: hello\nextra: world");
let result = validate_schema("name: hello\nextra: world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("integer"));
}
#[test]
fn should_produce_no_diagnostics_when_prefix_items_cover_all_items() {
let schema = JsonSchema {
prefix_items: Some(vec![string_schema(), integer_schema()]),
unevaluated_items: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("boolean".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
};
let docs = parse_docs("- hello\n- 42");
let result = validate_schema("- hello\n- 42", &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_diagnostic_for_unevaluated_item_beyond_prefix() {
let schema = JsonSchema {
prefix_items: Some(vec![string_schema()]),
unevaluated_items: Some(Box::new(integer_schema())),
..JsonSchema::default()
};
let docs = parse_docs("- hello\n- world");
let result = validate_schema("- hello\n- world", &docs, &schema, true);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("integer"));
}
#[test]
fn should_evaluate_properties_from_then_branch() {
let schema = JsonSchema {
if_schema: Some(Box::new(JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
})),
then_schema: Some(Box::new(object_schema_with_props(vec![(
"extra",
string_schema(),
)]))),
unevaluated_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let docs = parse_docs("name: hello\nextra: world");
let result = validate_schema("name: hello\nextra: world", &docs, &schema, true);
assert!(
result.iter().all(|d| !d.message.contains("extra")),
"extra should be evaluated by then"
);
}
#[test]
fn should_not_change_behavior_when_no_unevaluated_keywords() {
let schema = object_schema_with_props(vec![("name", string_schema())]);
let docs = parse_docs("name: hello\nextra: world");
let result = validate_schema("name: hello\nextra: world", &docs, &schema, true);
assert!(result.is_empty());
}
fn format_schema(fmt: &str) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
format: Some(fmt.to_string()),
..JsonSchema::default()
}
}
fn run_format(text: &str, fmt: &str) -> Vec<Diagnostic> {
let schema = format_schema(fmt);
let docs = parse_docs(text);
validate_schema(text, &docs, &schema, true)
}
#[test]
fn format_date_time_valid() {
assert!(run_format("2023-01-15T10:30:00Z", "date-time").is_empty());
assert!(run_format("2023-01-15T10:30:00+05:30", "date-time").is_empty());
assert!(run_format("2023-01-15T10:30:00.123Z", "date-time").is_empty());
}
#[test]
fn format_date_time_invalid() {
let result = run_format("not-a-date", "date-time");
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaFormat");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(result[0].message.contains("date-time"));
assert_eq!(run_format("2023-13-01T00:00:00Z", "date-time").len(), 1);
assert_eq!(run_format("2023-01-15 10:30:00Z", "date-time").len(), 1);
}
#[test]
fn format_date_valid() {
assert!(run_format("2023-01-15", "date").is_empty());
assert!(run_format("2024-02-29", "date").is_empty()); }
#[test]
fn format_date_invalid() {
assert_eq!(run_format("2023-13-01", "date").len(), 1); assert_eq!(run_format("2023-02-29", "date").len(), 1); assert_eq!(run_format("not-a-date", "date").len(), 1);
}
#[test]
fn format_email_valid() {
assert!(run_format("user@example.com", "email").is_empty());
assert!(run_format("a+b@sub.domain.org", "email").is_empty());
}
#[test]
fn format_email_invalid() {
assert_eq!(run_format("no-at-sign", "email").len(), 1);
assert_eq!(run_format("missing-domain-dot@nodot", "email").len(), 1);
assert_eq!(run_format("user-no-domain@", "email").len(), 1);
}
#[test]
fn format_ipv4_valid() {
assert!(run_format("192.168.1.1", "ipv4").is_empty());
assert!(run_format("0.0.0.0", "ipv4").is_empty());
assert!(run_format("255.255.255.255", "ipv4").is_empty());
}
#[test]
fn format_ipv4_invalid() {
assert_eq!(run_format("256.0.0.1", "ipv4").len(), 1);
assert_eq!(run_format("192.168.1", "ipv4").len(), 1);
assert_eq!(run_format("192.168.1.1.1", "ipv4").len(), 1);
assert_eq!(run_format("01.0.0.1", "ipv4").len(), 1); }
#[test]
fn format_ipv6_valid() {
assert!(run_format("2001:0db8:85a3:0000:0000:8a2e:0370:7334", "ipv6").is_empty());
assert!(run_format("::1", "ipv6").is_empty());
assert!(run_format("fe80::1", "ipv6").is_empty());
}
#[test]
fn format_ipv6_invalid() {
assert_eq!(
run_format(
"not::an::ipv6::address::with::too::many::groups::here",
"ipv6"
)
.len(),
1
);
}
#[test]
fn format_hostname_valid() {
assert!(run_format("example.com", "hostname").is_empty());
assert!(run_format("sub.example.com", "hostname").is_empty());
assert!(run_format("localhost", "hostname").is_empty());
}
#[test]
fn format_hostname_invalid() {
assert_eq!(run_format("-invalid.com", "hostname").len(), 1);
assert_eq!(run_format("invalid-.com", "hostname").len(), 1);
assert_eq!(run_format("invalid..com", "hostname").len(), 1);
}
#[test]
fn format_uri_valid() {
assert!(run_format("https://example.com/path", "uri").is_empty());
assert!(run_format("http://example.com", "uri").is_empty());
assert!(run_format("urn:isbn:0451450523", "uri").is_empty());
}
#[test]
fn format_uri_invalid() {
assert_eq!(run_format("not-a-uri", "uri").len(), 1);
assert_eq!(run_format("//no-scheme", "uri").len(), 1);
}
#[test]
fn format_uuid_valid() {
assert!(run_format("550e8400-e29b-41d4-a716-446655440000", "uuid").is_empty());
assert!(run_format("550E8400-E29B-41D4-A716-446655440000", "uuid").is_empty());
}
#[test]
fn format_uuid_invalid() {
assert_eq!(run_format("not-a-uuid", "uuid").len(), 1);
assert_eq!(
run_format("550e8400-e29b-41d4-a716-44665544000g", "uuid").len(),
1
);
assert_eq!(
run_format("550e8400e29b41d4a716446655440000", "uuid").len(),
1
);
}
#[test]
fn format_json_pointer_valid() {
assert!(run_format("", "json-pointer").is_empty()); assert!(run_format("/foo/bar", "json-pointer").is_empty());
assert!(run_format("/foo/0", "json-pointer").is_empty());
assert!(run_format("/a~0b", "json-pointer").is_empty()); assert!(run_format("/a~1b", "json-pointer").is_empty()); }
#[test]
fn format_json_pointer_invalid() {
assert_eq!(run_format("foo", "json-pointer").len(), 1); assert_eq!(run_format("/foo~2bar", "json-pointer").len(), 1); assert_eq!(run_format("/foo~", "json-pointer").len(), 1); }
#[test]
fn format_unknown_is_ignored() {
let result = run_format("anything", "some-unknown-format");
assert!(result.is_empty());
}
#[test]
fn format_validation_disabled_produces_no_format_diagnostics() {
let schema = format_schema("date");
let docs = parse_docs("not-a-date");
let result = validate_schema("not-a-date", &docs, &schema, false);
assert!(result.is_empty());
}
#[test]
fn format_regex_valid() {
assert!(run_format("^[a-z]+$", "regex").is_empty());
assert!(run_format(".*", "regex").is_empty());
}
#[test]
fn format_regex_invalid() {
assert_eq!(run_format("(unclosed-paren", "regex").len(), 1);
}
#[test]
fn format_idn_hostname_valid() {
assert!(run_format("example.com", "idn-hostname").is_empty());
assert!(run_format("xn--nxasmq6b.com", "idn-hostname").is_empty());
assert!(run_format("sub.example.org", "idn-hostname").is_empty());
}
#[test]
fn format_idn_hostname_invalid() {
assert_eq!(run_format("not a hostname", "idn-hostname").len(), 1);
assert_eq!(run_format("-bad-start.com", "idn-hostname").len(), 1);
}
#[test]
fn format_idn_email_valid() {
assert!(run_format("user@example.com", "idn-email").is_empty());
assert!(run_format("user@xn--nxasmq6b.com", "idn-email").is_empty());
}
#[test]
fn format_idn_email_invalid() {
assert_eq!(run_format("no-at-sign", "idn-email").len(), 1);
assert_eq!(run_format("user@-bad-domain.com", "idn-email").len(), 1);
}
#[test]
fn format_iri_valid() {
assert!(run_format("https://example.com/path", "iri").is_empty());
assert!(run_format("http://example.com", "iri").is_empty());
assert!(run_format("urn:isbn:0451450523", "iri").is_empty());
}
#[test]
fn format_iri_invalid() {
assert_eq!(run_format("not an iri", "iri").len(), 1);
assert_eq!(run_format("://missing-scheme", "iri").len(), 1);
}
#[test]
fn format_iri_reference_valid() {
assert!(run_format("https://example.com/path", "iri-reference").is_empty());
assert!(run_format("/relative/path", "iri-reference").is_empty());
assert!(run_format("relative/path", "iri-reference").is_empty());
}
#[test]
fn format_iri_reference_invalid() {
assert_eq!(run_format("not valid iri ref", "iri-reference").len(), 1);
}
fn content_schema(encoding: Option<&str>, media_type: Option<&str>) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
content_encoding: encoding.map(str::to_string),
content_media_type: media_type.map(str::to_string),
..JsonSchema::default()
}
}
fn run_content(
text: &str,
encoding: Option<&str>,
media_type: Option<&str>,
) -> Vec<Diagnostic> {
let schema = content_schema(encoding, media_type);
let docs = parse_docs(text);
validate_schema(text, &docs, &schema, true)
}
#[test]
fn content_encoding_base64_valid() {
assert!(run_content("aGVsbG8=", Some("base64"), None).is_empty());
assert!(run_content("", Some("base64"), None).is_empty());
}
#[test]
fn content_encoding_base64_invalid() {
assert_eq!(
run_content("not-valid-base64!!!", Some("base64"), None).len(),
1
);
}
#[test]
fn content_encoding_base64url_valid() {
assert!(run_content("aGVsbG8=", Some("base64url"), None).is_empty());
}
#[test]
fn content_encoding_base64url_invalid() {
assert_eq!(
run_content("not+valid/base64url!!!", Some("base64url"), None).len(),
1
);
}
#[test]
fn content_encoding_base32_valid() {
assert!(run_content("NBSWY3DPEB3W64TMMQ======", Some("base32"), None).is_empty());
}
#[test]
fn content_encoding_base32_invalid() {
assert_eq!(
run_content("not-valid-base32!!!", Some("base32"), None).len(),
1
);
}
#[test]
fn content_encoding_base16_valid() {
assert!(run_content("48656C6C6F", Some("base16"), None).is_empty());
assert!(run_content("48656c6c6f", Some("base16"), None).is_empty());
}
#[test]
fn content_encoding_base16_invalid() {
assert_eq!(run_content("ZZZZ", Some("base16"), None).len(), 1);
}
#[test]
fn content_encoding_unknown_ignored() {
assert!(run_content("anything", Some("base58"), None).is_empty());
}
#[test]
fn content_media_type_json_valid_no_encoding() {
let schema = content_schema(None, Some("application/json"));
let docs = parse_docs("\"42\"");
assert!(validate_schema("\"42\"", &docs, &schema, true).is_empty());
}
#[test]
fn content_media_type_json_invalid_no_encoding() {
assert_eq!(
run_content("not json", None, Some("application/json")).len(),
1
);
}
#[test]
fn content_encoding_and_media_type_valid() {
assert!(
run_content(
"eyJrZXkiOiJ2YWx1ZSJ9",
Some("base64"),
Some("application/json")
)
.is_empty()
);
}
#[test]
fn content_encoding_fails_skips_media_type_check() {
let diags = run_content(
"not-valid-base64!!!",
Some("base64"),
Some("application/json"),
);
assert_eq!(diags.len(), 1);
assert!(diags[0].code == Some(NumberOrString::String("schemaContentEncoding".to_string())));
}
#[test]
fn content_encoding_valid_media_type_invalid() {
let diags = run_content("bm90IGpzb24=", Some("base64"), Some("application/json"));
assert_eq!(diags.len(), 1);
assert!(
diags[0].code == Some(NumberOrString::String("schemaContentMediaType".to_string()))
);
}
#[test]
fn content_media_type_unknown_ignored() {
assert!(run_content("anything", None, Some("text/plain")).is_empty());
}
#[test]
fn content_validation_disabled_when_format_validation_off() {
let schema = content_schema(Some("base64"), Some("application/json"));
let docs = parse_docs("not-valid-base64!!!");
let result = validate_schema("not-valid-base64!!!", &docs, &schema, false);
assert!(result.is_empty());
}
fn content_schema_with_sub(
encoding: Option<&str>,
media_type: Option<&str>,
sub_schema: JsonSchema,
) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
content_encoding: encoding.map(str::to_string),
content_media_type: media_type.map(str::to_string),
content_schema: Some(Box::new(sub_schema)),
..JsonSchema::default()
}
}
#[test]
fn content_schema_base64_json_valid() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), Some("application/json"), sub);
let docs = parse_docs("\"NDI=\"");
assert!(
validate_schema("\"NDI=\"", &docs, &schema, true).is_empty(),
"valid base64-encoded integer should pass contentSchema validation"
);
}
#[test]
fn content_schema_base64_json_type_mismatch() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), Some("application/json"), sub);
let docs = parse_docs("\"ImhlbGxvIg==\"");
let result = validate_schema("\"ImhlbGxvIg==\"", &docs, &schema, true);
assert!(
result.iter().any(|d| code_of(d) == "schemaType"),
"string decoded where integer expected should produce schemaType error: {result:?}"
);
}
#[test]
fn content_schema_no_encoding_validates_raw_string() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(None, None, sub);
let docs = parse_docs("\"42\"");
assert!(
validate_schema("\"42\"", &docs, &schema, true).is_empty(),
"raw string '42' should validate as integer against contentSchema"
);
}
#[test]
fn content_schema_no_encoding_type_mismatch() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(None, None, sub);
let docs = parse_docs("\"hello\"");
let result = validate_schema("\"hello\"", &docs, &schema, true);
assert!(
result.iter().any(|d| code_of(d) == "schemaType"),
"string 'hello' should fail integer contentSchema: {result:?}"
);
}
#[test]
fn content_schema_skipped_when_encoding_fails() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), Some("application/json"), sub);
let docs = parse_docs("\"not-valid-base64!!!\"");
let result = validate_schema("\"not-valid-base64!!!\"", &docs, &schema, true);
assert!(
result.iter().any(|d| code_of(d) == "schemaContentEncoding"),
"should report encoding error: {result:?}"
);
assert!(
!result.iter().any(|d| code_of(d) == "schemaType"),
"should NOT check contentSchema when encoding fails: {result:?}"
);
}
#[test]
fn content_schema_skipped_when_media_type_fails() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(None, Some("application/json"), sub);
let docs = parse_docs("\"not json at all\"");
let result = validate_schema("\"not json at all\"", &docs, &schema, true);
assert!(
result
.iter()
.any(|d| code_of(d) == "schemaContentMediaType"),
"should report media type error: {result:?}"
);
assert!(
!result.iter().any(|d| code_of(d) == "schemaType"),
"should NOT check contentSchema when media type fails: {result:?}"
);
}
#[test]
fn content_schema_validates_embedded_yaml_mapping() {
let mut props = std::collections::HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
},
);
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some(props),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), None, sub);
let text = "\"bmFtZTogYWxpY2UK\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
result.is_empty(),
"embedded YAML mapping should validate: {result:?}"
);
}
#[test]
fn content_schema_validates_embedded_yaml_mapping_invalid() {
let mut props = std::collections::HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
},
);
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some(props),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), None, sub);
let text = "\"bmFtZTogNDIK\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
!result.is_empty(),
"embedded mapping with integer name should fail string check: {result:?}"
);
}
#[test]
fn content_schema_skipped_when_format_validation_off() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(None, None, sub);
let docs = parse_docs("\"hello\"");
let result = validate_schema("\"hello\"", &docs, &schema, false);
assert!(
result.is_empty(),
"contentSchema should not be checked when format_validation is off: {result:?}"
);
}
#[test]
fn content_schema_with_encoding_and_media_type_all_pass() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), Some("application/json"), sub);
let text = "\"eyJrZXkiOiAidmFsdWUifQ==\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
result.is_empty(),
"all three checks should pass: {result:?}"
);
}
#[test]
fn content_schema_decoded_yaml_invalid() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), None, sub);
let text = "\"OiBiYWQ6IFs=\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
result.iter().any(|d| code_of(d) == "schemaContentSchema"),
"unparseable decoded YAML should produce schemaContentSchema: {result:?}"
);
}
#[test]
fn content_schema_with_empty_decoded_content() {
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
};
let schema = content_schema_with_sub(None, None, sub);
let text = "\"\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
result.is_empty(),
"empty content should produce no diagnostics: {result:?}"
);
}
#[test]
fn content_schema_nested_sub_schema_uses_full_validation() {
let mut props = std::collections::HashMap::new();
props.insert(
"value".to_string(),
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
},
);
let sub = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some(props),
..JsonSchema::default()
};
let schema = content_schema_with_sub(Some("base64"), None, sub);
let text = "\"dmFsdWU6IDQyCg==\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(
!result.is_empty(),
"nested schema should catch type mismatch: {result:?}"
);
}
fn object_schema_with_cardinality(min: Option<u64>, max: Option<u64>) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
min_properties: min,
max_properties: max,
..JsonSchema::default()
}
}
#[test]
fn should_produce_error_when_object_has_fewer_properties_than_min_properties() {
let schema = object_schema_with_cardinality(Some(2), None);
let text = "name: Alice";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMinProperties");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_object_meets_min_properties() {
let schema = object_schema_with_cardinality(Some(2), None);
let text = "name: Alice\nage: 30";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_error_when_object_exceeds_max_properties() {
let schema = object_schema_with_cardinality(None, Some(1));
let text = "name: Alice\nage: 30";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMaxProperties");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_produce_no_diagnostics_when_object_meets_max_properties() {
let schema = object_schema_with_cardinality(None, Some(2));
let text = "name: Alice\nage: 30";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
fn tuple_schema_with_additional_items(
prefix: Vec<JsonSchema>,
additional_items: Option<AdditionalProperties>,
) -> JsonSchema {
JsonSchema {
prefix_items: Some(prefix),
additional_items,
..JsonSchema::default()
}
}
#[test]
fn should_produce_warning_for_extra_items_when_additional_items_false() {
let schema = tuple_schema_with_additional_items(
vec![string_schema()],
Some(AdditionalProperties::Denied),
);
let text = "- hello\n- extra";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaAdditionalItems");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
let msg = &result[0].message;
assert!(
msg.contains("[1]"),
"message should reference [1], got: {msg}"
);
}
#[test]
fn should_produce_no_diagnostics_when_array_exactly_matches_prefix_length_with_additional_items_false()
{
let schema = tuple_schema_with_additional_items(
vec![string_schema(), integer_schema()],
Some(AdditionalProperties::Denied),
);
let text = "- hello\n- 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_for_items_within_prefix_with_additional_items_false() {
let schema = tuple_schema_with_additional_items(
vec![string_schema(), integer_schema()],
Some(AdditionalProperties::Denied),
);
let text = "- hello";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_one_warning_per_extra_item_when_additional_items_false() {
let schema = tuple_schema_with_additional_items(
vec![string_schema()],
Some(AdditionalProperties::Denied),
);
let text = "- hello\n- extra1\n- extra2";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 2);
assert!(result.iter().all(|d| code_of(d) == "schemaAdditionalItems"));
}
#[test]
fn should_validate_extra_items_against_additional_items_schema_when_valid() {
let schema = tuple_schema_with_additional_items(
vec![string_schema()],
Some(AdditionalProperties::Schema(Box::new(integer_schema()))),
);
let text = "- hello\n- 42";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_type_diagnostic_when_extra_item_fails_additional_items_schema() {
let schema = tuple_schema_with_additional_items(
vec![string_schema()],
Some(AdditionalProperties::Schema(Box::new(integer_schema()))),
);
let text = "- hello\n- world";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
}
#[test]
fn should_produce_no_diagnostics_when_additional_items_false_and_prefix_items_set_from_prefix_items_key()
{
let schema = JsonSchema {
prefix_items: Some(vec![string_schema()]),
additional_items: None,
..JsonSchema::default()
};
let text = "- hello\n- extra";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn should_produce_no_diagnostics_when_additional_items_absent_and_extra_items_present() {
let schema = tuple_schema_with_additional_items(vec![string_schema()], None);
let text = "- hello\n- 42\n- extra";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert!(result.is_empty());
}
#[test]
fn type_mismatch_message_uses_value_at_path_subject() {
let schema = object_schema_with_props(vec![("replicas", integer_schema())]);
let text = "replicas: \"hello\"";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.starts_with("Value at"),
"message should start with 'Value at', got: {msg}"
);
assert!(
msg.contains("does not match type"),
"message should contain 'does not match type', got: {msg}"
);
assert!(
msg.contains("integer"),
"message should contain expected type 'integer', got: {msg}"
);
assert!(
msg.contains("string"),
"message should contain actual type 'string', got: {msg}"
);
}
#[test]
fn type_mismatch_message_includes_property_path() {
let spec_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
properties: Some([("replicas".to_string(), integer_schema())].into()),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let text = "spec:\n replicas: not-an-int";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("spec.replicas"),
"message should contain nested path 'spec.replicas', got: {msg}"
);
}
#[test]
fn minimum_inclusive_draft04_message_uses_below_minimum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(false),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 4");
let result = validate_schema("val: 4", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is below minimum 5"),
"message should contain 'is below minimum 5', got: {msg}"
);
assert!(
!msg.contains("(inclusive)"),
"message should not contain '(inclusive)', got: {msg}"
);
}
#[test]
fn minimum_exclusive_draft04_message_uses_below_exclusive_minimum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(true),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is below exclusive minimum 5"),
"message should contain 'is below exclusive minimum 5', got: {msg}"
);
assert!(
!msg.contains("(exclusive)"),
"message should not contain '(exclusive)', got: {msg}"
);
}
#[test]
fn maximum_inclusive_draft04_message_uses_above_maximum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(false),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 11");
let result = validate_schema("val: 11", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is above maximum 10"),
"message should contain 'is above maximum 10', got: {msg}"
);
assert!(
!msg.contains("(inclusive)"),
"message should not contain '(inclusive)', got: {msg}"
);
}
#[test]
fn maximum_exclusive_draft04_message_uses_above_exclusive_maximum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(true),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 10");
let result = validate_schema("val: 10", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is above exclusive maximum 10"),
"message should contain 'is above exclusive maximum 10', got: {msg}"
);
assert!(
!msg.contains("(exclusive)"),
"message should not contain '(exclusive)', got: {msg}"
);
}
#[test]
fn exclusive_minimum_draft06_message_uses_below_exclusive_minimum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 5");
let result = validate_schema("val: 5", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is below exclusive minimum 5"),
"message should contain 'is below exclusive minimum 5', got: {msg}"
);
assert!(
!msg.contains("must be greater than"),
"message should not contain old phrasing 'must be greater than', got: {msg}"
);
}
#[test]
fn exclusive_maximum_draft06_message_uses_above_exclusive_maximum_phrase() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: 10");
let result = validate_schema("val: 10", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("is above exclusive maximum 10"),
"message should contain 'is above exclusive maximum 10', got: {msg}"
);
assert!(
!msg.contains("must be less than"),
"message should not contain old phrasing 'must be less than', got: {msg}"
);
}
#[test]
fn any_of_message_includes_branch_count() {
let schema = JsonSchema {
any_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let any_of_diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("should have a schemaType diagnostic");
let msg = &any_of_diag.message;
assert!(
msg.contains('2'),
"message should contain branch count '2', got: {msg}"
);
assert!(
msg.contains("(anyOf)"),
"message should contain '(anyOf)', got: {msg}"
);
}
#[test]
fn any_of_message_branch_count_capped_at_max_branch_count() {
let branches: Vec<JsonSchema> = (0..25)
.map(|i| JsonSchema {
required: Some(vec![format!("field_{i}")]),
..JsonSchema::default()
})
.collect();
let schema = JsonSchema {
any_of: Some(branches),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let any_of_diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("should have a schemaType diagnostic");
let msg = &any_of_diag.message;
assert!(
msg.contains("20"),
"message should contain capped count '20', got: {msg}"
);
}
#[test]
fn one_of_zero_match_message_includes_branch_count() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let one_of_diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("should have a schemaType diagnostic");
let msg = &one_of_diag.message;
assert!(
msg.contains('2'),
"message should contain branch count '2', got: {msg}"
);
assert!(
msg.contains("oneOf schemas"),
"message should contain 'oneOf schemas', got: {msg}"
);
}
#[test]
fn one_of_multi_match_message_includes_passing_count() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
object_schema_with_props(vec![("a", string_schema())]),
]),
..JsonSchema::default()
};
let docs = parse_docs("a: hello");
let result = validate_schema("a: hello", &docs, &schema, true);
assert!(!result.is_empty());
let one_of_diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("should have a schemaType diagnostic");
let msg = &one_of_diag.message;
assert!(
msg.contains("expected exactly 1"),
"message should contain 'expected exactly 1', got: {msg}"
);
}
#[test]
fn one_of_multi_match_message_includes_total_count() {
let schema = JsonSchema {
one_of: Some(vec![
JsonSchema {
required: Some(vec!["a".to_string()]),
..JsonSchema::default()
},
object_schema_with_props(vec![("a", string_schema())]),
JsonSchema {
required: Some(vec!["b".to_string()]),
..JsonSchema::default()
},
]),
..JsonSchema::default()
};
let docs = parse_docs("a: hello");
let result = validate_schema("a: hello", &docs, &schema, true);
assert!(!result.is_empty());
let one_of_diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("should have a schemaType diagnostic");
let msg = &one_of_diag.message;
assert!(
msg.contains('3'),
"message should contain total count '3', got: {msg}"
);
assert!(
msg.contains('2'),
"message should contain passing count '2', got: {msg}"
);
}
#[test]
fn not_schema_message_references_not_keyword() {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
not: Some(Box::new(JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
..JsonSchema::default()
})),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: hello");
let result = validate_schema("val: hello", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("schema defined in 'not'"),
"message should contain \"schema defined in 'not'\", got: {msg}"
);
assert!(
!msg.contains("excluded schema"),
"message should not contain old phrasing 'excluded schema', got: {msg}"
);
}
#[test]
fn required_property_message_uses_object_at_subject() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema("age: 30", &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("Object at"),
"message should contain 'Object at', got: {msg}"
);
assert!(
msg.contains("is missing required property"),
"message should contain 'is missing required property', got: {msg}"
);
assert!(
!msg.contains("Missing required property"),
"message should not use old phrasing 'Missing required property', got: {msg}"
);
}
#[test]
fn required_property_message_uses_expected_label() {
let schema = JsonSchema {
required: Some(vec!["name".to_string(), "age".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("other: value");
let result = validate_schema("other: value", &docs, &schema, true);
assert!(!result.is_empty());
let msg = &result[0].message;
assert!(
msg.contains("Expected:"),
"message should contain 'Expected:', got: {msg}"
);
assert!(
!msg.contains("Expected properties:"),
"message should not contain old label 'Expected properties:', got: {msg}"
);
}
#[test]
fn required_property_message_includes_nested_path() {
let spec_schema = JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
required: Some(vec!["replicas".to_string()]),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let text = "spec:\n other: value";
let docs = parse_docs(text);
let result = validate_schema(text, &docs, &schema, true);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("Object at spec"),
"message should contain 'Object at spec', got: {msg}"
);
assert!(
msg.contains("replicas"),
"message should contain the missing property name 'replicas', got: {msg}"
);
}
}