use rlsp_yaml_parser::LineIndex;
use rlsp_yaml_parser::ScalarStyle;
use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::Node;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
use crate::lsp_util::span_to_lsp;
use crate::scalar_helpers;
use crate::schema::JsonSchema;
use crate::server::YamlVersion;
use super::context::Ctx;
use super::formats;
use super::support::{
MAX_PATTERN_LEN, format_path, get_regex, make_diagnostic, node_loc, yaml_to_json,
};
use super::validate_node;
pub(super) fn validate_scalar_constraints(
node: &Node<Span>,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
if let Node::Scalar {
value,
style,
tag,
loc,
..
} = node
{
let is_plain = matches!(style, ScalarStyle::Plain);
if tag.as_deref() == Some("tag:yaml.org,2002:str") {
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_lsp(node_loc(node), ctx.idx);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
"schemaConst",
format!("Value at {} must equal {}", format_path(path), const_val),
));
}
}
}
}
pub(super) fn validate_string_constraints(
s: &str,
loc: Span,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
let range = span_to_lsp(loc, ctx.idx);
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, ctx.idx);
}
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, ctx.idx);
}
}
}
pub(super) fn validate_format(
s: &str,
format: &str,
loc: Span,
path: &[String],
diagnostics: &mut Vec<Diagnostic>,
idx: &LineIndex,
) {
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_lsp(loc, idx),
DiagnosticSeverity::WARNING,
"schemaFormat",
format!(
"String at {} does not match format '{format}'",
format_path(path)
),
));
}
}
pub(super) fn validate_content(
s: &str,
schema: &JsonSchema,
loc: Span,
path: &[String],
diagnostics: &mut Vec<Diagnostic>,
idx: &LineIndex,
) {
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_lsp(loc, idx),
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_lsp(loc, idx),
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,
idx,
);
}
pub(super) fn validate_content_schema(
raw: &str,
decoded_bytes: Option<&[u8]>,
schema: &JsonSchema,
loc: Span,
path: &[String],
diagnostics: &mut Vec<Diagnostic>,
idx: &LineIndex,
) {
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_lsp(loc, idx),
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, doc.line_index());
validate_node(&doc.root, content_schema, &content_path, &mut ctx, 0);
}
}
pub(super) fn validate_numeric_constraints(
val: f64,
loc: Span,
schema: &JsonSchema,
path: &[String],
ctx: &mut Ctx<'_>,
) {
let range = span_to_lsp(loc, ctx.idx);
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),
),
));
}
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use tower_lsp::lsp_types::DiagnosticSeverity;
use crate::schema::{JsonSchema, SchemaType};
use crate::server::YamlVersion;
use crate::test_utils::parse_docs;
use serde_json::json;
use crate::schema_validation::support::test_fixtures::{
code_of, object_schema_with_props, string_schema,
};
use crate::schema_validation::validate_schema;
#[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));
}
#[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");
}
#[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"));
}
fn min_length_schema(min: u64) -> JsonSchema {
JsonSchema {
schema_type: Some(SchemaType::Single("string".to_string())),
min_length: Some(min),
..JsonSchema::default()
}
}
#[test]
fn tag_driven_string_scalar_applies_min_length() {
let schema = object_schema_with_props(vec![("value", min_length_schema(10))]);
let docs = parse_docs("value: hi");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().any(|d| code_of(d) == "schemaMinLength"),
"string scalar must have minLength applied"
);
}
#[test]
fn tag_driven_null_scalar_skips_min_length() {
let schema = object_schema_with_props(vec![("value", min_length_schema(10))]);
let docs = parse_docs("value: ~");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().all(|d| code_of(d) != "schemaMinLength"),
"null scalar must not have string constraints applied"
);
}
#[test]
fn tag_driven_bool_scalar_skips_min_length() {
let schema = object_schema_with_props(vec![("value", min_length_schema(10))]);
let docs = parse_docs("value: true");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().all(|d| code_of(d) != "schemaMinLength"),
"bool scalar must not have string constraints applied"
);
}
#[test]
fn tag_driven_integer_scalar_skips_min_length() {
let schema = object_schema_with_props(vec![("value", min_length_schema(10))]);
let docs = parse_docs("value: 42");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().all(|d| code_of(d) != "schemaMinLength"),
"integer scalar must not have string constraints applied"
);
}
#[test]
fn tag_driven_quoted_scalar_applies_min_length() {
let schema = object_schema_with_props(vec![("value", min_length_schema(10))]);
let docs = parse_docs("value: \"hi\"");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().any(|d| code_of(d) == "schemaMinLength"),
"quoted scalar is always a string — minLength must apply"
);
}
fn const_schema(val: serde_json::Value) -> JsonSchema {
JsonSchema {
const_value: Some(val),
..JsonSchema::default()
}
}
#[test]
fn tag_driven_null_tagged_scalar_matches_const_null() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(null)))]);
let docs = parse_docs("value: ~");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty(), "null scalar must match const: null");
}
#[test]
fn tag_driven_true_bool_matches_const_true() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(true)))]);
let docs = parse_docs("value: true");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty(), "true scalar must match const: true");
}
#[test]
fn tag_driven_false_bool_does_not_match_const_true() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(true)))]);
let docs = parse_docs("value: false");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().any(|d| code_of(d) == "schemaConst"),
"false scalar must not match const: true"
);
}
#[test]
fn tag_driven_integer_scalar_matches_const_number() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(42)))]);
let docs = parse_docs("value: 42");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(result.is_empty(), "integer 42 must match const: 42");
}
#[test]
fn tag_driven_quoted_null_looking_scalar_is_string_not_null() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(null)))]);
let docs = parse_docs("value: \"~\"");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().any(|d| code_of(d) == "schemaConst"),
"quoted '~' is a string, not null — must not match const: null"
);
}
#[test]
fn tag_driven_quoted_bool_looking_scalar_is_string_not_bool() {
let schema = object_schema_with_props(vec![("value", const_schema(json!(true)))]);
let docs = parse_docs("value: \"true\"");
let result = validate_schema(&docs, &schema, true, YamlVersion::V1_2);
assert!(
result.iter().any(|d| code_of(d) == "schemaConst"),
"quoted 'true' is a string, not bool — must not match const: true"
);
}
fn content_schema_helper(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<tower_lsp::lsp_types::Diagnostic> {
let schema = content_schema_helper(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_helper(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() {
use tower_lsp::lsp_types::NumberOrString;
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() {
use tower_lsp::lsp_types::NumberOrString;
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_helper(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:?}"
);
}
#[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_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_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_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");
}
}