use std::cell::RefCell;
use std::collections::HashMap;
use regex::RegexBuilder;
use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::{Document, Node};
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
fn span_to_range(loc: Span) -> Range {
#[expect(
clippy::cast_possible_truncation,
reason = "LSP positions are u32; documents exceeding 4 billion lines/columns are not a realistic concern"
)]
Range::new(
Position::new(
loc.start.line.saturating_sub(1) as u32,
loc.start.column as u32,
),
Position::new(loc.end.line.saturating_sub(1) as u32, loc.end.column as u32),
)
}
const fn node_loc(node: &Node<Span>) -> Span {
match node {
Node::Scalar { loc, .. }
| Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Alias { loc, .. } => *loc,
}
}
use crate::scalar_helpers;
use crate::schema::{AdditionalProperties, JsonSchema, SchemaType};
use crate::server::YamlVersion;
mod formats;
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,
yaml_version: YamlVersion,
}
impl<'a> Ctx<'a> {
const fn new(
diagnostics: &'a mut Vec<Diagnostic>,
format_validation: bool,
yaml_version: YamlVersion,
) -> Self {
Self {
diagnostics,
format_validation,
yaml_version,
}
}
}
#[must_use]
pub fn validate_schema(
docs: &[Document<Span>],
schema: &JsonSchema,
format_validation: bool,
yaml_version: YamlVersion,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut ctx = Ctx::new(&mut diagnostics, format_validation, yaml_version);
for doc in docs {
validate_node(&doc.root, schema, &[], &mut ctx, 0);
}
diagnostics
}
fn effective_yaml_type<'a>(
node: &Node<Span>,
schema_type: &crate::schema::SchemaType,
yaml_type: &'a str,
is_plain: bool,
yaml_version: YamlVersion,
) -> &'a str {
if yaml_version == YamlVersion::V1_1
&& is_plain
&& yaml_type == "string"
&& single_type_or_contains(schema_type, "boolean")
{
if let Node::Scalar { value, .. } = node {
if scalar_helpers::is_yaml11_bool(value) {
return "boolean";
}
}
}
yaml_type
}
fn validate_type(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) -> bool {
use rlsp_yaml_parser::ScalarStyle;
let Some(schema_type) = &schema.schema_type else {
return true;
};
let yaml_type = yaml_type_name(node);
let is_plain =
matches!(node, Node::Scalar { style, .. } if matches!(style, ScalarStyle::Plain));
let effective = effective_yaml_type(node, schema_type, yaml_type, is_plain, ctx.yaml_version);
if !type_matches(effective, schema_type) {
let range = span_to_range(node_loc(node));
let (code, message) = type_mismatch_diagnostic(
node,
schema_type,
path,
effective,
is_plain,
ctx.yaml_version,
);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
code,
message,
));
return false;
}
if ctx.yaml_version == YamlVersion::V1_2
&& is_plain
&& effective == "string"
&& single_type_or_contains(schema_type, "string")
{
emit_yaml11_string_warnings(node, path, ctx);
}
true
}
fn type_mismatch_diagnostic(
node: &Node<Span>,
schema_type: &crate::schema::SchemaType,
path: &[String],
effective_type: &str,
is_plain: bool,
yaml_version: YamlVersion,
) -> (&'static str, String) {
if yaml_version == YamlVersion::V1_2
&& is_plain
&& effective_type == "string"
&& single_type_or_contains(schema_type, "boolean")
{
if let Node::Scalar { value, .. } = node {
if scalar_helpers::is_yaml11_bool(value) {
let canonical = scalar_helpers::yaml11_bool_canonical(value);
return (
"schemaYaml11BooleanType",
format!(
"Value at {} does not match type: expected boolean, got string. \
\"{value}\" is not a boolean in YAML 1.2 — use {canonical} instead. \
(In YAML 1.1, \"{value}\" was a boolean.)",
format_path(path),
),
);
}
}
}
(
"schemaType",
format!(
"Value at {} does not match type: expected {}, got {}",
format_path(path),
display_schema_type(schema_type),
effective_type,
),
)
}
fn emit_yaml11_string_warnings(node: &Node<Span>, path: &[String], ctx: &mut Ctx<'_>) {
let Node::Scalar { value, .. } = node else {
return;
};
if scalar_helpers::is_yaml11_bool(value) {
let canonical = scalar_helpers::yaml11_bool_canonical(value);
let range = span_to_range(node_loc(node));
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaYaml11Boolean",
format!(
"Value at {} is a string in YAML 1.2 but a boolean in YAML 1.1. \
Most tools use 1.1 parsers and will interpret \"{value}\" as {canonical}. \
Quote it (\"{value}\") or use {canonical}.",
format_path(path),
),
));
} else if scalar_helpers::is_yaml11_octal(value) {
let decimal = i64::from_str_radix(&value[1..], 8).unwrap_or(0);
let yaml12 = format!("0o{}", &value[1..]);
let range = span_to_range(node_loc(node));
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"schemaYaml11Octal",
format!(
"Value at {} is a string in YAML 1.2 but octal {decimal} in YAML 1.1. \
Quote it (\"{value}\") or use {yaml12} (YAML 1.2 only).",
format_path(path),
),
));
}
}
fn validate_node(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
if depth > MAX_VALIDATION_DEPTH {
return;
}
if !validate_type(node, schema, path, ctx) {
return;
}
if let Some(enum_values) = &schema.enum_values
&& let Some(yaml_val) = yaml_to_json(node)
&& !enum_values.contains(&yaml_val)
{
let range = span_to_range(node_loc(node));
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, loc, .. } = node {
validate_mapping(entries, *loc, schema, path, ctx, depth);
}
if let Node::Sequence { items, loc, .. } = node {
validate_sequence(items, *loc, 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 = span_to_range(node_loc(k));
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>],
seq_loc: 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 = span_to_range(node_loc(item));
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, seq_loc, schema, path, ctx, depth);
}
fn validate_array_constraints(
seq: &[Node<Span>],
seq_loc: 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 = span_to_range(seq_loc);
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 = span_to_range(seq_loc);
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 = span_to_range(seq_loc);
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, seq_loc, contains_schema, schema, path, ctx, depth);
}
}
fn validate_mapping_constraints(
entries: &[(Node<Span>, Node<Span>)],
mapping_loc: 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 = span_to_range(mapping_loc);
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 = span_to_range(mapping_loc);
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>],
seq_loc: Span,
contains_schema: &JsonSchema,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
let format_validation = ctx.format_validation;
let yaml_version = ctx.yaml_version;
let match_count = seq
.iter()
.filter(|item| {
let mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, yaml_version);
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 = span_to_range(seq_loc);
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 = span_to_range(seq_loc);
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::ScalarStyle;
if let Node::Scalar {
value, style, loc, ..
} = 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, *loc, schema, path, ctx);
}
if is_plain {
let numeric_val = scalar_helpers::parse_integer(value)
.map(|i| {
#[expect(clippy::cast_precision_loss, reason = "integer-to-f64 for numeric comparison; precision loss acceptable here")]
{
i as f64
}
})
.or_else(|| scalar_helpers::parse_float(value));
if let Some(val) = numeric_val {
validate_numeric_constraints(val, *loc, 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 = span_to_range(node_loc(node));
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,
loc: Span,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
let range = span_to_range(loc);
if let Some(pattern) = &schema.pattern {
if pattern.len() > MAX_PATTERN_LEN {
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) {
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaPattern",
format!(
"Value at {} does not match pattern: {}",
format_path(path),
pattern
),
));
}
} else {
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 {
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 {
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, loc, path, ctx.diagnostics);
}
if schema.content_encoding.is_some()
|| schema.content_media_type.is_some()
|| schema.content_schema.is_some()
{
validate_content(s, schema, loc, path, ctx.diagnostics);
}
}
}
fn validate_format(
s: &str,
format: &str,
loc: Span,
path: &[String],
diagnostics: &mut Vec<Diagnostic>,
) {
let valid = match format {
"date-time" => formats::is_valid_date_time(s),
"date" => formats::is_valid_date(s),
"time" => formats::is_valid_time(s),
"duration" => formats::is_valid_duration(s),
"email" => formats::is_valid_email(s),
"ipv4" => formats::is_valid_ipv4(s),
"ipv6" => formats::is_valid_ipv6(s),
"hostname" => formats::is_valid_hostname(s),
"uri" => formats::is_valid_uri(s),
"uri-reference" => formats::is_valid_uri_reference(s),
"uri-template" => formats::is_valid_uri_template(s),
"uuid" => formats::is_valid_uuid(s),
"regex" => formats::is_valid_regex(s),
"json-pointer" => formats::is_valid_json_pointer(s),
"relative-json-pointer" => formats::is_valid_relative_json_pointer(s),
"idn-hostname" => formats::is_valid_idn_hostname(s),
"idn-email" => formats::is_valid_idn_email(s),
"iri" => formats::is_valid_iri(s),
"iri-reference" => formats::is_valid_iri_reference(s),
_ => return,
};
if !valid {
diagnostics.push(make_diagnostic(
span_to_range(loc),
DiagnosticSeverity::WARNING,
"schemaFormat",
format!(
"String at {} does not match format '{format}'",
format_path(path)
),
));
}
}
fn validate_content(
s: &str,
schema: &JsonSchema,
loc: Span,
path: &[String],
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 {
diagnostics.push(make_diagnostic(
span_to_range(loc),
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 {
diagnostics.push(make_diagnostic(
span_to_range(loc),
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, loc, path, diagnostics);
}
fn validate_content_schema(
raw: &str,
decoded_bytes: Option<&[u8]>,
schema: &JsonSchema,
loc: Span,
path: &[String],
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 {
diagnostics.push(make_diagnostic(
span_to_range(loc),
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 mut ctx = Ctx::new(diagnostics, true, YamlVersion::V1_2);
validate_node(&doc.root, content_schema, &content_path, &mut ctx, 0);
}
}
fn validate_numeric_constraints(
val: f64,
loc: Span,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
let range = span_to_range(loc);
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 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 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 {
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 {
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 {
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>)],
mapping_loc: Span,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
depth: usize,
) {
validate_mapping_constraints(entries, mapping_loc, 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 = span_to_range(mapping_loc);
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 = span_to_range(node_loc(k));
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::ScalarStyle::Plain,
anchor: None,
anchor_loc: None,
tag: None,
tag_loc: None,
loc: rlsp_yaml_parser::Span {
start: rlsp_yaml_parser::Pos::ORIGIN,
end: rlsp_yaml_parser::Pos::ORIGIN,
},
leading_comments: None,
trailing_comment: None,
};
validate_node(&key_node, pn_schema, path, ctx, depth + 1);
}
}
validate_dependencies(entries, mapping_loc, schema, path, ctx, depth);
}
fn validate_dependencies(
entries: &[(Node<Span>, Node<Span>)],
mapping_loc: 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 = span_to_range(mapping_loc);
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(),
style: rlsp_yaml_parser::CollectionStyle::Block,
anchor: None,
anchor_loc: None,
tag: None,
tag_loc: None,
loc: rlsp_yaml_parser::Span {
start: rlsp_yaml_parser::Pos::ORIGIN,
end: rlsp_yaml_parser::Pos::ORIGIN,
},
leading_comments: None,
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 = span_to_range(node_loc(value));
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 = span_to_range(node_loc(value));
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,
) {
let format_validation = ctx.format_validation;
let yaml_version = ctx.yaml_version;
let node_range = span_to_range(node_loc(node));
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 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, yaml_version);
validate_node(node, branch, path, &mut probe, depth + 1);
scratch.is_empty()
});
if !any_passes {
ctx.diagnostics.push(make_diagnostic(
node_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 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, yaml_version);
validate_node(node, branch, path, &mut probe, depth + 1);
scratch.is_empty()
})
.count();
if passing == 0 {
ctx.diagnostics.push(make_diagnostic(
node_range,
DiagnosticSeverity::ERROR,
"schemaType",
format!(
"Value at {} does not match any of the {total} oneOf schemas",
format_path(path)
),
));
} else if passing > 1 {
ctx.diagnostics.push(make_diagnostic(
node_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 mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, yaml_version);
validate_node(node, not_schema, path, &mut probe, depth + 1);
if scratch.is_empty() {
ctx.diagnostics.push(make_diagnostic(
node_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 mut scratch = Vec::new();
let mut probe = Ctx::new(&mut scratch, format_validation, yaml_version);
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::ScalarStyle;
use scalar_helpers::PlainScalarKind;
match node {
Node::Scalar { value, style, .. } => {
if !matches!(style, ScalarStyle::Plain) {
return "string";
}
match scalar_helpers::classify_plain_scalar(value) {
PlainScalarKind::Null => "null",
PlainScalarKind::Bool => "boolean",
PlainScalarKind::Integer => "integer",
PlainScalarKind::Float => "number",
PlainScalarKind::String => "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_or_contains(schema_type: &SchemaType, target: &str) -> bool {
match schema_type {
SchemaType::Single(t) => t == target,
SchemaType::Multiple(ts) => ts.iter().any(|t| t == target),
}
}
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::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 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)]
#[expect(
clippy::indexing_slicing,
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
reason = "test code"
)]
mod tests {
use rstest::rstest;
use super::*;
use crate::schema::{AdditionalProperties, JsonSchema, SchemaType};
use crate::test_utils::parse_docs;
use serde_json::json;
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 boolean_schema() -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("boolean".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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaRequired");
assert!(result[0].message.contains("name"));
assert!(result[0].message.contains("spec"));
}
#[rstest]
#[case::string_where_integer_expected(
object_schema_with_props(vec![("count", integer_schema())]),
"count: \"hello\""
)]
#[case::integer_where_string_expected(
object_schema_with_props(vec![("name", string_schema())]),
"name: 42"
)]
#[case::boolean_where_string_expected(
object_schema_with_props(vec![("name", string_schema())]),
"name: true"
)]
#[case::mapping_where_string_expected(
object_schema_with_props(vec![("name", string_schema())]),
"name:\n nested: value"
)]
#[case::sequence_where_object_expected(
object_schema_with_props(vec![("config", JsonSchema {
schema_type: Some(SchemaType::Single("object".to_string())),
..JsonSchema::default()
})]),
"config:\n - item"
)]
#[case::null_where_string_expected(
object_schema_with_props(vec![("name", string_schema())]),
"name: ~"
)]
fn type_mismatch_produces_schematype_error(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn type_mismatch_message_names_expected_type() {
let schema = object_schema_with_props(vec![("count", integer_schema())]);
let docs = parse_docs("count: \"hello\"");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("integer"));
}
#[rstest]
#[case::string_value_matches_string_type(
object_schema_with_props(vec![("name", string_schema())]),
"name: Alice"
)]
#[case::null_in_type_array_accepts_null(
object_schema_with_props(vec![("name", JsonSchema {
schema_type: Some(SchemaType::Multiple(vec![
"string".to_string(),
"null".to_string(),
])),
..JsonSchema::default()
})]),
"name: ~"
)]
#[case::no_type_specified_accepts_any(
object_schema_with_props(vec![("name", JsonSchema::default())]),
"name: 42"
)]
#[case::integer_value_matches_integer_type(
object_schema_with_props(vec![("port", integer_schema())]),
"port: 8080"
)]
#[case::boolean_value_matches_boolean_type(
object_schema_with_props(vec![("enabled", JsonSchema {
schema_type: Some(SchemaType::Single("boolean".to_string())),
..JsonSchema::default()
})]),
"enabled: true"
)]
#[case::sequence_value_matches_array_type(
object_schema_with_props(vec![("items", JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
..JsonSchema::default()
})]),
"items:\n - one\n - two"
)]
fn type_match_produces_no_diagnostics(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty());
}
#[rstest]
#[case::string_value_in_string_enum(
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()
})]),
"env: staging"
)]
#[case::integer_value_in_integer_enum(
object_schema_with_props(vec![("level", JsonSchema {
enum_values: Some(vec![json!(1), json!(2), json!(3)]),
..JsonSchema::default()
})]),
"level: 2"
)]
#[case::string_value_in_mixed_type_enum(
object_schema_with_props(vec![("value", JsonSchema {
enum_values: Some(vec![json!("auto"), json!(0), serde_json::Value::Null]),
..JsonSchema::default()
})]),
"value: auto"
)]
fn enum_match_produces_no_diagnostics(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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'"
);
}
#[rstest]
#[case::integer_value_not_in_enum(
object_schema_with_props(vec![("level", JsonSchema {
enum_values: Some(vec![json!(1), json!(2), json!(3)]),
..JsonSchema::default()
})]),
"level: 5"
)]
fn enum_mismatch_produces_schemaenum_error(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaEnum");
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &l1, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
assert!(!result.is_empty());
assert!(
result
.iter()
.all(|d| d.source == Some("rlsp-yaml".to_string()))
);
}
#[rstest]
#[case::required_violation(
JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
},
"age: 30",
"schemaRequired"
)]
#[case::type_violation(
object_schema_with_props(vec![("count", integer_schema())]),
"count: hello",
"schemaType"
)]
#[case::enum_violation(
object_schema_with_props(vec![("env", JsonSchema {
enum_values: Some(vec![json!("prod"), json!("staging")]),
..JsonSchema::default()
})]),
"env: testing",
"schemaEnum"
)]
fn violation_produces_correct_code(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(!result.is_empty());
assert_eq!(
result[0].code,
Some(NumberOrString::String(expected_code.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(&docs, &schema, true, YamlVersion::V1_2);
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()
))
);
}
#[rstest]
#[case::required_violation(
JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
},
"age: 30"
)]
#[case::type_violation(
object_schema_with_props(vec![("count", integer_schema())]),
"count: hello"
)]
#[case::enum_violation(
object_schema_with_props(vec![("env", JsonSchema {
enum_values: Some(vec![json!("prod")]),
..JsonSchema::default()
})]),
"env: testing"
)]
fn violation_produces_error_severity(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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, YamlVersion::V1_2);
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(&[], &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&[], &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPattern");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::pattern_exceeds_max_length("a".repeat(1025))]
#[case::pattern_fails_to_compile("[invalid".to_string())]
fn pattern_rejected_produces_schemapatternlimit_warning(#[case] pattern: String) {
let schema = object_schema_with_props(vec![(
"val",
JsonSchema {
pattern: Some(pattern),
..JsonSchema::default()
},
)]);
let docs = parse_docs("val: anything");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaPatternLimit");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[rstest]
#[case::string_meets_min_length(
object_schema_with_props(vec![("name", JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(3),
..JsonSchema::default()
})]),
"name: abc"
)]
#[case::string_meets_max_length(
object_schema_with_props(vec![("name", JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
max_length: Some(10),
..JsonSchema::default()
})]),
"name: hello"
)]
fn string_length_constraint_valid_produces_no_diagnostics(
#[case] schema: JsonSchema,
#[case] text: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty());
}
#[rstest]
#[case::string_shorter_than_min_length(
object_schema_with_props(vec![("name", JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(5),
..JsonSchema::default()
})]),
"name: hi",
"schemaMinLength"
)]
#[case::string_exceeds_max_length(
object_schema_with_props(vec![("name", JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
max_length: Some(3),
..JsonSchema::default()
})]),
"name: toolong",
"schemaMaxLength"
)]
fn string_length_constraint_violated_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::integer_meets_minimum(
object_schema_with_props(vec![("port", JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
minimum: Some(1.0),
..JsonSchema::default()
})]),
"port: 80"
)]
#[case::integer_meets_maximum(
object_schema_with_props(vec![("port", JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
maximum: Some(65535.0),
..JsonSchema::default()
})]),
"port: 8080"
)]
fn numeric_inclusive_bound_valid_produces_no_diagnostics(
#[case] schema: JsonSchema,
#[case] text: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty());
}
#[rstest]
#[case::integer_below_minimum(
object_schema_with_props(vec![("port", JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
minimum: Some(1.0),
..JsonSchema::default()
})]),
"port: 0",
"schemaMinimum"
)]
#[case::integer_exceeds_maximum(
object_schema_with_props(vec![("port", JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
maximum: Some(65535.0),
..JsonSchema::default()
})]),
"port: 99999",
"schemaMaximum"
)]
fn numeric_inclusive_bound_violated_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::value_equals_exclusive_minimum(
object_schema_with_props(vec![("val", JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(true),
..JsonSchema::default()
})]),
"val: 5",
"schemaMinimum"
)]
#[case::value_equals_exclusive_maximum(
object_schema_with_props(vec![("val", JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(true),
..JsonSchema::default()
})]),
"val: 10",
"schemaMaximum"
)]
fn draft04_exclusive_bound_at_boundary_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty());
}
#[rstest]
#[case::value_equals_exclusive_minimum(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
})]),
"val: 5",
"schemaMinimum"
)]
#[case::value_equals_exclusive_maximum(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
})]),
"val: 10",
"schemaMaximum"
)]
fn draft06_exclusive_bound_at_boundary_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
}
#[rstest]
#[case::value_exceeds_exclusive_minimum(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
})]),
"val: 6"
)]
#[case::value_below_exclusive_maximum(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
})]),
"val: 9"
)]
fn draft06_exclusive_bound_past_boundary_produces_no_diagnostics(
#[case] schema: JsonSchema,
#[case] text: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaMultipleOf");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::string_value_equals_const(
object_schema_with_props(vec![("version", JsonSchema {
const_value: Some(json!("v1")),
..JsonSchema::default()
})]),
"version: v1"
)]
#[case::integer_value_equals_const(
object_schema_with_props(vec![("level", JsonSchema {
const_value: Some(json!(42)),
..JsonSchema::default()
})]),
"level: 42"
)]
fn const_match_produces_no_diagnostics(#[case] schema: JsonSchema, #[case] text: &str) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaConst");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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()
}
}
#[rstest]
#[case::fewer_than_min_items(
object_schema_with_props(vec![("tags", array_schema(Some(2), None, None))]),
"tags:\n - a",
"schemaMinItems"
)]
#[case::exceeds_max_items(
object_schema_with_props(vec![("tags", array_schema(None, Some(2), None))]),
"tags:\n - a\n - b\n - c",
"schemaMaxItems"
)]
#[case::duplicate_items_when_unique_required(
object_schema_with_props(vec![("tags", array_schema(None, None, Some(true)))]),
"tags:\n - foo\n - bar\n - foo",
"schemaUniqueItems"
)]
fn array_constraint_violated_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::meets_min_items(
object_schema_with_props(vec![("tags", array_schema(Some(2), None, None))]),
"tags:\n - a\n - b"
)]
#[case::meets_max_items(
object_schema_with_props(vec![("tags", array_schema(None, Some(2), None))]),
"tags:\n - a\n - b"
)]
#[case::all_unique_with_unique_items_true(
object_schema_with_props(vec![("tags", array_schema(None, None, Some(true)))]),
"tags:\n - foo\n - bar\n - baz"
)]
#[case::duplicates_allowed_when_unique_items_false(
object_schema_with_props(vec![("tags", array_schema(None, None, Some(false)))]),
"tags:\n - foo\n - foo"
)]
fn array_constraint_satisfied_produces_no_diagnostics(
#[case] schema: JsonSchema,
#[case] text: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty());
}
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(&docs, &schema, true, YamlVersion::V1_2)
}
#[rstest]
#[case::base64_valid("aGVsbG8=", "base64")]
#[case::base64_empty_valid("", "base64")]
#[case::base64url_valid("aGVsbG8=", "base64url")]
#[case::base32_valid("NBSWY3DPEB3W64TMMQ======", "base32")]
#[case::base16_uppercase_valid("48656C6C6F", "base16")]
#[case::base16_lowercase_valid("48656c6c6f", "base16")]
fn content_encoding_valid_produces_no_diagnostics(#[case] value: &str, #[case] encoding: &str) {
assert!(run_content(value, Some(encoding), None).is_empty());
}
#[rstest]
#[case::base64_invalid("not-valid-base64!!!", "base64")]
#[case::base64url_invalid("not+valid/base64url!!!", "base64url")]
#[case::base32_invalid("not-valid-base32!!!", "base32")]
#[case::base16_invalid("ZZZZ", "base16")]
fn content_encoding_invalid_produces_error(#[case] value: &str, #[case] encoding: &str) {
assert_eq!(run_content(value, Some(encoding), 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(&docs, &schema, true, YamlVersion::V1_2).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(&docs, &schema, false, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2).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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2).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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, false, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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()
}
}
#[rstest]
#[case::fewer_than_min_properties(
object_schema_with_cardinality(Some(2), None),
"name: Alice",
"schemaMinProperties"
)]
#[case::exceeds_max_properties(
object_schema_with_cardinality(None, Some(1)),
"name: Alice\nage: 30",
"schemaMaxProperties"
)]
fn object_property_count_violated_produces_error(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_code: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), expected_code);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[rstest]
#[case::meets_min_properties(
object_schema_with_cardinality(Some(2), None),
"name: Alice\nage: 30"
)]
#[case::meets_max_properties(
object_schema_with_cardinality(None, Some(2)),
"name: Alice\nage: 30"
)]
fn object_property_count_satisfied_produces_no_diagnostics(
#[case] schema: JsonSchema,
#[case] text: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains("spec.replicas"),
"message should contain nested path 'spec.replicas', got: {msg}"
);
}
#[rstest]
#[case::inclusive_minimum_draft04(
object_schema_with_props(vec![("val", JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(false),
..JsonSchema::default()
})]),
"val: 4",
"is below minimum 5",
"(inclusive)"
)]
#[case::exclusive_minimum_draft04(
object_schema_with_props(vec![("val", JsonSchema {
minimum: Some(5.0),
exclusive_minimum_draft04: Some(true),
..JsonSchema::default()
})]),
"val: 5",
"is below exclusive minimum 5",
"(exclusive)"
)]
#[case::inclusive_maximum_draft04(
object_schema_with_props(vec![("val", JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(false),
..JsonSchema::default()
})]),
"val: 11",
"is above maximum 10",
"(inclusive)"
)]
#[case::exclusive_maximum_draft04(
object_schema_with_props(vec![("val", JsonSchema {
maximum: Some(10.0),
exclusive_maximum_draft04: Some(true),
..JsonSchema::default()
})]),
"val: 10",
"is above exclusive maximum 10",
"(exclusive)"
)]
#[case::exclusive_minimum_draft06(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_minimum: Some(5.0),
..JsonSchema::default()
})]),
"val: 5",
"is below exclusive minimum 5",
"must be greater than"
)]
#[case::exclusive_maximum_draft06(
object_schema_with_props(vec![("val", JsonSchema {
exclusive_maximum: Some(10.0),
..JsonSchema::default()
})]),
"val: 10",
"is above exclusive maximum 10",
"must be less than"
)]
fn numeric_bound_message_uses_correct_phrase(
#[case] schema: JsonSchema,
#[case] text: &str,
#[case] expected_phrase: &str,
#[case] excluded_phrase: &str,
) {
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(
msg.contains(expected_phrase),
"message should contain '{expected_phrase}', got: {msg}"
);
assert!(
!msg.contains(excluded_phrase),
"message should not contain '{excluded_phrase}', 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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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(&docs, &schema, true, YamlVersion::V1_2);
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}"
);
}
#[rstest]
#[case::yes_lowercase("yes")]
#[case::yes_titlecase("Yes")]
#[case::yes_uppercase("YES")]
#[case::no_lowercase("no")]
#[case::no_titlecase("No")]
#[case::no_uppercase("NO")]
#[case::on_lowercase("on")]
#[case::on_titlecase("On")]
#[case::on_uppercase("ON")]
#[case::off_lowercase("off")]
#[case::off_titlecase("Off")]
#[case::off_uppercase("OFF")]
#[case::y_lowercase("y")]
#[case::y_uppercase("Y")]
#[case::n_lowercase("n")]
#[case::n_uppercase("N")]
fn schema_yaml11_boolean_warning_for_string_field(#[case] value: &str) {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let text = format!("flag: {value}");
let docs = parse_docs(&text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(
result.len(),
1,
"expected one diagnostic for '{value}', got: {result:?}"
);
assert_eq!(code_of(&result[0]), "schemaYaml11Boolean");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn schema_yaml11_boolean_message_contains_value_and_canonical() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let docs = parse_docs("flag: yes");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(msg.contains("yes"), "message should contain 'yes': {msg}");
assert!(
msg.contains("true"),
"message should contain 'true' (canonical): {msg}"
);
assert!(
msg.contains("string") || msg.contains("1.2"),
"message should mention string/1.2: {msg}"
);
}
#[test]
fn schema_yaml11_boolean_no_warning_for_quoted_scalar() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let text = "flag: \"yes\"";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.is_empty(),
"quoted scalar should not trigger warning: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_no_warning_for_ordinary_string() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let text = "flag: hello";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.is_empty(),
"ordinary string should not trigger warning: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_suppressed_in_v1_1_mode() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let text = "flag: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_1);
assert!(
result.is_empty(),
"1.1 mode should suppress schema yaml11 boolean warning: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_no_warning_when_field_not_in_schema() {
let schema = object_schema_with_props(vec![("other", string_schema())]);
let text = "flag: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.is_empty(),
"field without schema should not trigger warning: {result:?}"
);
}
#[rstest]
#[case::mode_0755("0755")]
#[case::mode_007("007")]
#[case::mode_01("01")]
#[case::mode_077("077")]
fn schema_yaml11_octal_warning_for_string_field(#[case] value: &str) {
let schema = object_schema_with_props(vec![("mode", string_schema())]);
let text = format!("mode: {value}");
let docs = parse_docs(&text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(
result.len(),
1,
"expected one diagnostic for '{value}', got: {result:?}"
);
assert_eq!(code_of(&result[0]), "schemaYaml11Octal");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn schema_yaml11_octal_message_contains_value_decimal_and_hint() {
let schema = object_schema_with_props(vec![("mode", string_schema())]);
let text = "mode: 0755";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(msg.contains("0755"), "message should contain '0755': {msg}");
assert!(
msg.contains("493"),
"message should contain decimal '493': {msg}"
);
assert!(
msg.contains("0o755"),
"message should contain '0o755': {msg}"
);
}
#[test]
fn schema_yaml11_octal_no_warning_for_quoted_scalar() {
let schema = object_schema_with_props(vec![("mode", string_schema())]);
let text = "mode: \"0755\"";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.is_empty(),
"quoted scalar should not trigger warning: {result:?}"
);
}
#[rstest]
#[case::decimal("42")]
#[case::zero("0")]
#[case::yaml12_octal("0o755")]
fn schema_yaml11_octal_no_warning_for_non_octal(#[case] value: &str) {
let schema = object_schema_with_props(vec![("mode", string_schema())]);
let text = format!("mode: {value}");
let docs = parse_docs(&text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().all(|d| code_of(d) != "schemaYaml11Octal"),
"should not emit schemaYaml11Octal for '{value}': {result:?}"
);
}
#[test]
fn schema_yaml11_octal_suppressed_in_v1_1_mode() {
let schema = object_schema_with_props(vec![("mode", string_schema())]);
let text = "mode: 0755";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_1);
assert!(
result.is_empty(),
"1.1 mode should suppress schema yaml11 octal warning: {result:?}"
);
}
#[rstest]
#[case::yes_lowercase("yes")]
#[case::yes_titlecase("Yes")]
#[case::yes_uppercase("YES")]
#[case::no_lowercase("no")]
#[case::no_titlecase("No")]
#[case::no_uppercase("NO")]
#[case::on_lowercase("on")]
#[case::on_titlecase("On")]
#[case::on_uppercase("ON")]
#[case::off_lowercase("off")]
#[case::off_titlecase("Off")]
#[case::off_uppercase("OFF")]
#[case::y_lowercase("y")]
#[case::y_uppercase("Y")]
#[case::n_lowercase("n")]
#[case::n_uppercase("N")]
fn schema_yaml11_boolean_type_error_for_boolean_field(#[case] value: &str) {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = format!("enabled: {value}");
let docs = parse_docs(&text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(
result.len(),
1,
"expected one diagnostic for '{value}', got: {result:?}"
);
assert_eq!(code_of(&result[0]), "schemaYaml11BooleanType");
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn schema_yaml11_boolean_type_message_explains_1_1_context() {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = "enabled: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
let msg = &result[0].message;
assert!(msg.contains("yes"), "message should contain 'yes': {msg}");
assert!(
msg.contains("boolean"),
"message should mention boolean: {msg}"
);
assert!(
msg.contains("1.1"),
"message should reference YAML 1.1: {msg}"
);
assert!(
msg.contains("true") || msg.contains("false"),
"message should suggest true/false: {msg}"
);
}
#[rstest]
#[case::true_value("true")]
#[case::false_value("false")]
fn schema_yaml11_boolean_type_no_error_for_yaml12_booleans(#[case] value: &str) {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = format!("enabled: {value}");
let docs = parse_docs(&text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.is_empty(),
"YAML 1.2 boolean should not produce error: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_type_generic_error_for_non_1_1_mismatch() {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = "enabled: hello";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(result.len(), 1);
assert_eq!(code_of(&result[0]), "schemaType");
assert!(
!result[0].message.contains("1.1"),
"generic mismatch should not mention YAML 1.1: {}",
result[0].message
);
}
#[test]
fn schema_yaml11_boolean_type_suppressed_in_v1_1_mode() {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = "enabled: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_1);
assert!(
result.is_empty(),
"in 1.1 mode 'yes' is a valid boolean — no error expected: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_no_schema_type_for_string_field() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let text = "flag: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().all(|d| code_of(d) != "schemaType"),
"should not emit schemaType for string field with 1.1 bool: {result:?}"
);
}
#[test]
fn schema_yaml11_boolean_type_emits_exactly_one_diagnostic() {
let schema = object_schema_with_props(vec![("enabled", boolean_schema())]);
let text = "enabled: yes";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert_eq!(
result.len(),
1,
"exactly one diagnostic expected: {result:?}"
);
}
#[test]
fn diagnostic_range_type_mismatch_points_at_value_node() {
let schema = object_schema_with_props(vec![("age", integer_schema())]);
let docs = parse_docs("age: hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 5, "start column");
assert_eq!(diag.range.end.line, 0, "end line");
assert_eq!(diag.range.end.character, 10, "end column");
}
#[test]
fn diagnostic_range_missing_required_points_at_mapping() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let docs = parse_docs("age: 30");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaRequired")
.expect("expected a schemaRequired diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 0, "start column");
}
#[test]
fn diagnostic_range_additional_property_points_at_key_node() {
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(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaAdditionalProperty")
.expect("expected a schemaAdditionalProperty diagnostic");
assert_eq!(diag.range.start.line, 1, "start line");
assert_eq!(diag.range.start.character, 0, "start column");
assert_eq!(diag.range.end.line, 1, "end line");
assert_eq!(diag.range.end.character, 5, "end column");
}
#[test]
fn diagnostic_range_format_validation_points_at_value_node() {
let date_schema = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
format: Some("date".to_string()),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("date", date_schema)]);
let docs = parse_docs("date: not-a-date");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaFormat")
.expect("expected a schemaFormat diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 6, "start column");
assert_eq!(diag.range.end.line, 0, "end line");
assert_eq!(diag.range.end.character, 16, "end column");
}
#[test]
fn diagnostic_range_yaml11_string_warning_points_at_value_node() {
let schema = object_schema_with_props(vec![("flag", string_schema())]);
let docs = parse_docs("flag: yes");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaYaml11Boolean")
.expect("expected a schemaYaml11Boolean diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 6, "start column");
assert_eq!(diag.range.end.line, 0, "end line");
assert_eq!(diag.range.end.character, 9, "end column");
}
#[test]
fn diagnostic_range_composition_error_points_at_node() {
let schema = JsonSchema {
any_of: Some(vec![string_schema(), string_schema()]),
..JsonSchema::default()
};
let docs = parse_docs("42");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 0, "start column");
assert_eq!(diag.range.end.line, 0, "end line");
assert_eq!(diag.range.end.character, 2, "end column");
}
#[test]
fn diagnostic_range_deeply_nested_violation_points_at_correct_node() {
let inner = integer_schema();
let arr = JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
items: Some(Box::new(inner)),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("items", arr)]);
let docs = parse_docs("items:\n - hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 1, "start line");
assert_eq!(diag.range.start.character, 4, "start column");
assert_eq!(diag.range.end.line, 1, "end line");
assert_eq!(diag.range.end.character, 9, "end column");
}
#[test]
fn diagnostic_range_type_mismatch_nested_scalar() {
let port_schema = integer_schema();
let spec_schema = object_schema_with_props(vec![("port", port_schema)]);
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let docs = parse_docs("spec:\n port: hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 1, "start line");
assert_eq!(diag.range.start.character, 8, "start column");
}
#[test]
fn diagnostic_range_missing_required_uses_ast_mapping_loc() {
let schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let text = "age: 30";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaRequired")
.expect("expected a schemaRequired diagnostic");
let root_loc = node_loc(&docs[0].root);
#[expect(
clippy::cast_possible_truncation,
reason = "test: position values are small"
)]
let expected_start = Position::new(
root_loc.start.line.saturating_sub(1) as u32,
root_loc.start.column as u32,
);
assert_eq!(
diag.range.start, expected_start,
"range must match mapping loc"
);
}
#[test]
fn diagnostic_range_missing_required_nested_mapping() {
let spec_schema = JsonSchema {
required: Some(vec!["name".to_string()]),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", spec_schema)]);
let docs = parse_docs("spec:\n other: value");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaRequired")
.expect("expected a schemaRequired diagnostic");
assert_eq!(
diag.range.start.line, 1,
"nested mapping is on 0-indexed line 1"
);
}
#[test]
fn diagnostic_range_additional_property_indented_key() {
let inner_schema = JsonSchema {
properties: Some([("name".to_string(), string_schema())].into()),
additional_properties: Some(AdditionalProperties::Denied),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("spec", inner_schema)]);
let docs = parse_docs("spec:\n name: Alice\n bad: x");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaAdditionalProperty")
.expect("expected a schemaAdditionalProperty diagnostic");
assert_eq!(diag.range.start.line, 2, "start line");
assert_eq!(diag.range.start.character, 2, "start column");
}
#[test]
fn diagnostic_range_oneof_zero_match() {
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("val: hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
}
#[test]
fn diagnostic_range_three_level_nested_violation() {
let c_schema = integer_schema();
let b_schema = object_schema_with_props(vec![("c", c_schema)]);
let a_schema = object_schema_with_props(vec![("b", b_schema)]);
let schema = object_schema_with_props(vec![("a", a_schema)]);
let docs = parse_docs("a:\n b:\n c: hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 2, "start line");
assert_eq!(diag.range.start.character, 7, "start column");
}
#[test]
fn diagnostic_range_content_schema_uses_outer_scalar_loc() {
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(&docs, &schema, true, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(
diag.range.start.line, 0,
"must point at outer scalar, not inner content"
);
}
#[test]
fn diagnostic_range_enum_violation_points_at_scalar() {
let env_schema = JsonSchema {
enum_values: Some(vec![
serde_json::Value::String("prod".to_string()),
serde_json::Value::String("staging".to_string()),
]),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("env", env_schema)]);
let docs = parse_docs("env: testing");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaEnum")
.expect("expected a schemaEnum diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 5, "start column");
}
#[test]
fn diagnostic_range_min_length_violation_points_at_scalar() {
let code_schema = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(5),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("code", code_schema)]);
let docs = parse_docs("code: hi");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaMinLength")
.expect("expected a schemaMinLength diagnostic");
assert_eq!(diag.range.start.line, 0, "start line");
assert_eq!(diag.range.start.character, 6, "start column");
}
#[test]
fn diagnostic_range_min_items_uses_sequence_loc() {
let tags_schema = JsonSchema {
schema_type: Some(SchemaType::Single("array".to_string())),
min_items: Some(2),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![("tags", tags_schema)]);
let text = "tags:\n - a";
let docs = parse_docs(text);
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaMinItems")
.expect("expected a schemaMinItems diagnostic");
let seq_loc = if let Node::Mapping { entries, .. } = &docs[0].root {
let (_, v) = entries
.iter()
.find(|(k, _)| matches!(k, Node::Scalar { value, .. } if value == "tags"))
.expect("tags key");
node_loc(v)
} else {
panic!("expected mapping root");
};
#[expect(
clippy::cast_possible_truncation,
reason = "test: position values are small"
)]
let expected_start = Position::new(
seq_loc.start.line.saturating_sub(1) as u32,
seq_loc.start.column as u32,
);
assert_eq!(
diag.range.start, expected_start,
"range must match sequence loc"
);
}
#[test]
fn diagnostic_range_uses_zero_based_lines() {
let schema = object_schema_with_props(vec![("x", integer_schema())]);
let docs = parse_docs("x: bad");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 0, "must be 0-based (not 1-based)");
}
#[test]
fn diagnostic_range_second_line_is_correct() {
let schema =
object_schema_with_props(vec![("ok", integer_schema()), ("bad", integer_schema())]);
let docs = parse_docs("ok: 1\nbad: hello");
let result = validate_schema(&docs, &schema, false, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaType")
.expect("expected a schemaType diagnostic");
assert_eq!(diag.range.start.line, 1, "second line is 0-indexed 1");
}
#[test]
fn diagnostic_range_format_violation_third_line() {
let date_schema = JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
format: Some("date".to_string()),
..JsonSchema::default()
};
let schema = object_schema_with_props(vec![
("a", string_schema()),
("b", string_schema()),
("c", date_schema),
]);
let docs = parse_docs("a: foo\nb: bar\nc: not-a-date");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
let diag = result
.iter()
.find(|d| code_of(d) == "schemaFormat")
.expect("expected a schemaFormat diagnostic");
assert_eq!(diag.range.start.line, 2, "third line is 0-indexed 2");
}
}