use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProvenanceStep {
pub node: NodeId,
pub object: &'static str,
pub operation: &'static str,
pub value: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Provenance {
pub steps: Vec<ProvenanceStep>,
}
impl Provenance {
pub fn push(
mut self,
node: NodeId,
object: &'static str,
operation: &'static str,
value: impl Into<String>,
) -> Self {
self.steps.push(ProvenanceStep {
node,
object,
operation,
value: value.into(),
});
self
}
}
#[derive(Clone, Debug)]
pub enum RuntimeValue {
Coordinates(Coordinates),
Number {
value: f64,
provenance: Provenance,
},
Text {
value: String,
provenance: Provenance,
},
}
impl RuntimeValue {
pub fn number(value: f64, provenance: Provenance) -> Self {
Self::Number { value, provenance }
}
pub fn text(value: impl Into<String>, provenance: Provenance) -> Self {
Self::Text {
value: value.into(),
provenance,
}
}
pub fn provenance(&self) -> &Provenance {
match self {
Self::Coordinates(coordinates) => &coordinates.provenance,
Self::Number { provenance, .. } | Self::Text { provenance, .. } => provenance,
}
}
pub const fn kind(&self) -> &'static str {
match self {
Self::Coordinates(_) => "coordinates",
Self::Number { .. } => "number",
Self::Text { .. } => "text",
}
}
}
#[derive(Clone, Debug)]
pub struct Coordinates {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub viewport_width: f32,
pub viewport_height: f32,
pub coordinate_space: &'static str,
pub expected_space: &'static str,
pub provenance: Provenance,
}
impl Coordinates {
#[allow(clippy::too_many_arguments)]
pub fn new(
x: f32,
y: f32,
width: f32,
height: f32,
viewport_width: f32,
viewport_height: f32,
coordinate_space: &'static str,
expected_space: &'static str,
provenance: Provenance,
) -> Self {
Self {
x,
y,
width,
height,
viewport_width,
viewport_height,
coordinate_space,
expected_space,
provenance,
}
}
}
#[derive(Clone, Debug)]
pub struct RuntimeCheckFailure {
pub check: &'static str,
pub message: String,
pub provenance: Provenance,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuntimeCheckSpec {
CoordinatesInViewport,
FiniteNumber,
NumberInRange {
min: i64,
max: i64,
},
NonEmptyText,
TextLength {
min: usize,
max: usize,
},
}
impl RuntimeCheckSpec {
pub const fn name(self) -> &'static str {
match self {
Self::CoordinatesInViewport => "coordinates_in_viewport",
Self::FiniteNumber => "finite_number",
Self::NumberInRange { .. } => "number_in_range",
Self::NonEmptyText => "non_empty_text",
Self::TextLength { .. } => "text_length",
}
}
pub fn expression(self) -> String {
match self {
Self::CoordinatesInViewport => "crate::COORDINATES_IN_VIEWPORT".to_owned(),
Self::FiniteNumber => "crate::FINITE_NUMBER".to_owned(),
Self::NumberInRange { min, max } => {
format!("crate::RuntimeCheckSpec::number_in_range({min}, {max})")
}
Self::NonEmptyText => "crate::NON_EMPTY_TEXT".to_owned(),
Self::TextLength { min, max } => {
format!("crate::RuntimeCheckSpec::text_length({min}, {max})")
}
}
}
pub const fn number_in_range(min: i64, max: i64) -> Self {
Self::NumberInRange { min, max }
}
pub const fn text_length(min: usize, max: usize) -> Self {
Self::TextLength { min, max }
}
pub fn parse_list(value: &str) -> Result<Vec<Self>, String> {
let value = value.trim().trim_start_matches('[').trim_end_matches(']');
if value.is_empty() {
return Ok(Vec::new());
}
split_items(value)
.into_iter()
.map(|item| Self::parse_one(item.trim()))
.collect()
}
fn parse_one(value: &str) -> Result<Self, String> {
let value = value.trim();
let value = value.strip_prefix("crate::").unwrap_or(value);
let value = value
.strip_prefix("RuntimeCheckSpec::")
.unwrap_or(value)
.trim();
match value {
"COORDINATES_IN_VIEWPORT" | "coordinates_in_viewport" => {
return Ok(Self::CoordinatesInViewport);
}
"FINITE_NUMBER" | "finite_number" => return Ok(Self::FiniteNumber),
"NON_EMPTY_TEXT" | "non_empty_text" => return Ok(Self::NonEmptyText),
_ => {}
}
if let Some(arguments) = value
.strip_prefix("number_in_range(")
.and_then(|rest| rest.strip_suffix(')'))
{
let mut values = arguments.split(',').map(str::trim);
let min = values
.next()
.ok_or_else(|| "number_in_range requires min and max".to_owned())?
.parse::<i64>()
.map_err(|_| "number_in_range min must be an integer".to_owned())?;
let max = values
.next()
.ok_or_else(|| "number_in_range requires min and max".to_owned())?
.parse::<i64>()
.map_err(|_| "number_in_range max must be an integer".to_owned())?;
if values.next().is_some() {
return Err("number_in_range accepts exactly two integers".to_owned());
}
return Ok(Self::NumberInRange { min, max });
}
if let Some(arguments) = value
.strip_prefix("text_length(")
.and_then(|rest| rest.strip_suffix(')'))
{
let mut values = arguments.split(',').map(str::trim);
let min = values
.next()
.ok_or_else(|| "text_length requires min and max".to_owned())?
.parse::<usize>()
.map_err(|_| "text_length min must be an unsigned integer".to_owned())?;
let max = values
.next()
.ok_or_else(|| "text_length requires min and max".to_owned())?
.parse::<usize>()
.map_err(|_| "text_length max must be an unsigned integer".to_owned())?;
if values.next().is_some() {
return Err("text_length accepts exactly two integers".to_owned());
}
return Ok(Self::TextLength { min, max });
}
Err(format!("unknown runtime check `{value}`"))
}
pub fn run(self, value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
match self {
Self::CoordinatesInViewport => check_coordinates(value),
Self::FiniteNumber => check_finite_number(value),
Self::NumberInRange { min, max } => check_number_range(value, min, max),
Self::NonEmptyText => check_non_empty_text(value),
Self::TextLength { min, max } => check_text_length(value, min, max),
}
}
}
pub const COORDINATES_IN_VIEWPORT: RuntimeCheckSpec = RuntimeCheckSpec::CoordinatesInViewport;
pub const FINITE_NUMBER: RuntimeCheckSpec = RuntimeCheckSpec::FiniteNumber;
pub const NON_EMPTY_TEXT: RuntimeCheckSpec = RuntimeCheckSpec::NonEmptyText;
fn split_items(value: &str) -> Vec<&str> {
let mut items = Vec::new();
let mut start = 0;
let mut depth = 0usize;
for (index, character) in value.char_indices() {
match character {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
items.push(value[start..index].trim());
start = index + character.len_utf8();
}
_ => {}
}
}
items.push(value[start..].trim());
items
}
fn wrong_kind(check: &'static str, expected: &str, value: &RuntimeValue) -> RuntimeCheckFailure {
RuntimeCheckFailure {
check,
message: format!("expected {expected} data, received {}", value.kind()),
provenance: value.provenance().clone(),
}
}
fn check_coordinates(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
let RuntimeValue::Coordinates(coordinates) = value else {
return Err(wrong_kind("coordinates_in_viewport", "coordinate", value));
};
let finite = [
coordinates.x,
coordinates.y,
coordinates.width,
coordinates.height,
coordinates.viewport_width,
coordinates.viewport_height,
]
.into_iter()
.all(f32::is_finite);
if !finite {
return Err(RuntimeCheckFailure {
check: "coordinates_in_viewport",
message: "coordinate contains NaN or infinity".to_owned(),
provenance: coordinates.provenance.clone(),
});
}
if coordinates.coordinate_space != coordinates.expected_space {
return Err(RuntimeCheckFailure {
check: "coordinates_in_viewport",
message: format!(
"coordinate space `{}` does not match expected `{}`",
coordinates.coordinate_space, coordinates.expected_space
),
provenance: coordinates.provenance.clone(),
});
}
let inside = coordinates.x >= 0.0
&& coordinates.y >= 0.0
&& coordinates.width >= 0.0
&& coordinates.height >= 0.0
&& coordinates.x + coordinates.width <= coordinates.viewport_width
&& coordinates.y + coordinates.height <= coordinates.viewport_height;
if inside {
Ok(())
} else {
Err(RuntimeCheckFailure {
check: "coordinates_in_viewport",
message: format!(
"rect ({:.1}, {:.1}, {:.1}, {:.1}) exceeds viewport ({:.1}, {:.1})",
coordinates.x,
coordinates.y,
coordinates.width,
coordinates.height,
coordinates.viewport_width,
coordinates.viewport_height
),
provenance: coordinates.provenance.clone(),
})
}
}
fn check_finite_number(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
let RuntimeValue::Number { value, provenance } = value else {
return Err(wrong_kind("finite_number", "number", value));
};
if value.is_finite() {
Ok(())
} else {
Err(RuntimeCheckFailure {
check: "finite_number",
message: format!("number `{value}` is NaN or infinite"),
provenance: provenance.clone(),
})
}
}
fn check_number_range(value: &RuntimeValue, min: i64, max: i64) -> Result<(), RuntimeCheckFailure> {
let RuntimeValue::Number { value, provenance } = value else {
return Err(wrong_kind("number_in_range", "number", value));
};
if min > max {
return Err(RuntimeCheckFailure {
check: "number_in_range",
message: format!("invalid check bounds: minimum {min} exceeds maximum {max}"),
provenance: provenance.clone(),
});
}
for bound in [min, max] {
if (bound as f64) as i128 != i128::from(bound) {
return Err(RuntimeCheckFailure {
check: "number_in_range",
message: format!(
"invalid check bounds: {bound} cannot be represented exactly as a number"
),
provenance: provenance.clone(),
});
}
}
if value.is_finite() && *value >= min as f64 && *value <= max as f64 {
Ok(())
} else {
Err(RuntimeCheckFailure {
check: "number_in_range",
message: format!("number `{value}` is outside inclusive range {min}..={max}"),
provenance: provenance.clone(),
})
}
}
fn check_non_empty_text(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
let RuntimeValue::Text { value, provenance } = value else {
return Err(wrong_kind("non_empty_text", "text", value));
};
if value.trim().is_empty() {
Err(RuntimeCheckFailure {
check: "non_empty_text",
message: "text is empty or whitespace only".to_owned(),
provenance: provenance.clone(),
})
} else {
Ok(())
}
}
fn check_text_length(
value: &RuntimeValue,
min: usize,
max: usize,
) -> Result<(), RuntimeCheckFailure> {
let RuntimeValue::Text { value, provenance } = value else {
return Err(wrong_kind("text_length", "text", value));
};
let length = value.chars().count();
if min <= max && (min..=max).contains(&length) {
Ok(())
} else {
Err(RuntimeCheckFailure {
check: "text_length",
message: if min > max {
format!("invalid check bounds: minimum {min} exceeds maximum {max}")
} else {
format!("text length {length} is outside inclusive range {min}..={max}")
},
provenance: provenance.clone(),
})
}
}
#[cfg(test)]
#[path = "runtime_checks_tests.rs"]
mod runtime_checks_tests;