1use super::*;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ProvenanceStep {
11 pub node: NodeId,
14 pub object: &'static str,
17 pub operation: &'static str,
20 pub value: String,
23}
24
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct Provenance {
29 pub steps: Vec<ProvenanceStep>,
32}
33
34impl Provenance {
35 pub fn push(
38 mut self,
39 node: NodeId,
40 object: &'static str,
41 operation: &'static str,
42 value: impl Into<String>,
43 ) -> Self {
44 self.steps.push(ProvenanceStep {
45 node,
46 object,
47 operation,
48 value: value.into(),
49 });
50 self
51 }
52}
53
54#[derive(Clone, Debug)]
57pub enum RuntimeValue {
58 Coordinates(Coordinates),
61 Number {
64 value: f64,
67 provenance: Provenance,
70 },
71 Text {
74 value: String,
77 provenance: Provenance,
80 },
81}
82
83impl RuntimeValue {
84 pub fn number(value: f64, provenance: Provenance) -> Self {
87 Self::Number { value, provenance }
88 }
89
90 pub fn text(value: impl Into<String>, provenance: Provenance) -> Self {
93 Self::Text {
94 value: value.into(),
95 provenance,
96 }
97 }
98
99 pub fn provenance(&self) -> &Provenance {
102 match self {
103 Self::Coordinates(coordinates) => &coordinates.provenance,
104 Self::Number { provenance, .. } | Self::Text { provenance, .. } => provenance,
105 }
106 }
107
108 pub const fn kind(&self) -> &'static str {
111 match self {
112 Self::Coordinates(_) => "coordinates",
113 Self::Number { .. } => "number",
114 Self::Text { .. } => "text",
115 }
116 }
117}
118
119#[derive(Clone, Debug)]
122pub struct Coordinates {
123 pub x: f32,
126 pub y: f32,
129 pub width: f32,
132 pub height: f32,
135 pub viewport_width: f32,
138 pub viewport_height: f32,
141 pub coordinate_space: &'static str,
144 pub expected_space: &'static str,
147 pub provenance: Provenance,
150}
151
152impl Coordinates {
153 #[allow(clippy::too_many_arguments)]
156 pub fn new(
157 x: f32,
158 y: f32,
159 width: f32,
160 height: f32,
161 viewport_width: f32,
162 viewport_height: f32,
163 coordinate_space: &'static str,
164 expected_space: &'static str,
165 provenance: Provenance,
166 ) -> Self {
167 Self {
168 x,
169 y,
170 width,
171 height,
172 viewport_width,
173 viewport_height,
174 coordinate_space,
175 expected_space,
176 provenance,
177 }
178 }
179}
180
181#[derive(Clone, Debug)]
184pub struct RuntimeCheckFailure {
185 pub check: &'static str,
188 pub message: String,
191 pub provenance: Provenance,
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq)]
199pub enum RuntimeCheckSpec {
200 CoordinatesInViewport,
203 FiniteNumber,
206 NumberInRange {
209 min: i64,
212 max: i64,
215 },
216 NonEmptyText,
219 TextLength {
222 min: usize,
225 max: usize,
228 },
229}
230
231impl RuntimeCheckSpec {
232 pub const fn name(self) -> &'static str {
235 match self {
236 Self::CoordinatesInViewport => "coordinates_in_viewport",
237 Self::FiniteNumber => "finite_number",
238 Self::NumberInRange { .. } => "number_in_range",
239 Self::NonEmptyText => "non_empty_text",
240 Self::TextLength { .. } => "text_length",
241 }
242 }
243
244 pub fn expression(self) -> String {
247 match self {
248 Self::CoordinatesInViewport => "crate::COORDINATES_IN_VIEWPORT".to_owned(),
249 Self::FiniteNumber => "crate::FINITE_NUMBER".to_owned(),
250 Self::NumberInRange { min, max } => {
251 format!("crate::RuntimeCheckSpec::number_in_range({min}, {max})")
252 }
253 Self::NonEmptyText => "crate::NON_EMPTY_TEXT".to_owned(),
254 Self::TextLength { min, max } => {
255 format!("crate::RuntimeCheckSpec::text_length({min}, {max})")
256 }
257 }
258 }
259
260 pub const fn number_in_range(min: i64, max: i64) -> Self {
263 Self::NumberInRange { min, max }
264 }
265
266 pub const fn text_length(min: usize, max: usize) -> Self {
269 Self::TextLength { min, max }
270 }
271
272 pub fn parse_list(value: &str) -> Result<Vec<Self>, String> {
275 let value = value.trim().trim_start_matches('[').trim_end_matches(']');
276 if value.is_empty() {
277 return Ok(Vec::new());
278 }
279 split_items(value)
280 .into_iter()
281 .map(|item| Self::parse_one(item.trim()))
282 .collect()
283 }
284
285 fn parse_one(value: &str) -> Result<Self, String> {
286 let value = value.trim();
287 let value = value.strip_prefix("crate::").unwrap_or(value);
288 let value = value
289 .strip_prefix("RuntimeCheckSpec::")
290 .unwrap_or(value)
291 .trim();
292 match value {
293 "COORDINATES_IN_VIEWPORT" | "coordinates_in_viewport" => {
294 return Ok(Self::CoordinatesInViewport);
295 }
296 "FINITE_NUMBER" | "finite_number" => return Ok(Self::FiniteNumber),
297 "NON_EMPTY_TEXT" | "non_empty_text" => return Ok(Self::NonEmptyText),
298 _ => {}
299 }
300 if let Some(arguments) = value
301 .strip_prefix("number_in_range(")
302 .and_then(|rest| rest.strip_suffix(')'))
303 {
304 let mut values = arguments.split(',').map(str::trim);
305 let min = values
306 .next()
307 .ok_or_else(|| "number_in_range requires min and max".to_owned())?
308 .parse::<i64>()
309 .map_err(|_| "number_in_range min must be an integer".to_owned())?;
310 let max = values
311 .next()
312 .ok_or_else(|| "number_in_range requires min and max".to_owned())?
313 .parse::<i64>()
314 .map_err(|_| "number_in_range max must be an integer".to_owned())?;
315 if values.next().is_some() {
316 return Err("number_in_range accepts exactly two integers".to_owned());
317 }
318 return Ok(Self::NumberInRange { min, max });
319 }
320 if let Some(arguments) = value
321 .strip_prefix("text_length(")
322 .and_then(|rest| rest.strip_suffix(')'))
323 {
324 let mut values = arguments.split(',').map(str::trim);
325 let min = values
326 .next()
327 .ok_or_else(|| "text_length requires min and max".to_owned())?
328 .parse::<usize>()
329 .map_err(|_| "text_length min must be an unsigned integer".to_owned())?;
330 let max = values
331 .next()
332 .ok_or_else(|| "text_length requires min and max".to_owned())?
333 .parse::<usize>()
334 .map_err(|_| "text_length max must be an unsigned integer".to_owned())?;
335 if values.next().is_some() {
336 return Err("text_length accepts exactly two integers".to_owned());
337 }
338 return Ok(Self::TextLength { min, max });
339 }
340 Err(format!("unknown runtime check `{value}`"))
341 }
342
343 pub fn run(self, value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
346 match self {
347 Self::CoordinatesInViewport => check_coordinates(value),
348 Self::FiniteNumber => check_finite_number(value),
349 Self::NumberInRange { min, max } => check_number_range(value, min, max),
350 Self::NonEmptyText => check_non_empty_text(value),
351 Self::TextLength { min, max } => check_text_length(value, min, max),
352 }
353 }
354}
355
356pub const COORDINATES_IN_VIEWPORT: RuntimeCheckSpec = RuntimeCheckSpec::CoordinatesInViewport;
359
360pub const FINITE_NUMBER: RuntimeCheckSpec = RuntimeCheckSpec::FiniteNumber;
363
364pub const NON_EMPTY_TEXT: RuntimeCheckSpec = RuntimeCheckSpec::NonEmptyText;
367
368fn split_items(value: &str) -> Vec<&str> {
369 let mut items = Vec::new();
370 let mut start = 0;
371 let mut depth = 0usize;
372 for (index, character) in value.char_indices() {
373 match character {
374 '(' => depth += 1,
375 ')' => depth = depth.saturating_sub(1),
376 ',' if depth == 0 => {
377 items.push(value[start..index].trim());
378 start = index + character.len_utf8();
379 }
380 _ => {}
381 }
382 }
383 items.push(value[start..].trim());
384 items
385}
386
387fn wrong_kind(check: &'static str, expected: &str, value: &RuntimeValue) -> RuntimeCheckFailure {
388 RuntimeCheckFailure {
389 check,
390 message: format!("expected {expected} data, received {}", value.kind()),
391 provenance: value.provenance().clone(),
392 }
393}
394
395fn check_coordinates(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
396 let RuntimeValue::Coordinates(coordinates) = value else {
397 return Err(wrong_kind("coordinates_in_viewport", "coordinate", value));
398 };
399 let finite = [
400 coordinates.x,
401 coordinates.y,
402 coordinates.width,
403 coordinates.height,
404 coordinates.viewport_width,
405 coordinates.viewport_height,
406 ]
407 .into_iter()
408 .all(f32::is_finite);
409 if !finite {
410 return Err(RuntimeCheckFailure {
411 check: "coordinates_in_viewport",
412 message: "coordinate contains NaN or infinity".to_owned(),
413 provenance: coordinates.provenance.clone(),
414 });
415 }
416 if coordinates.coordinate_space != coordinates.expected_space {
417 return Err(RuntimeCheckFailure {
418 check: "coordinates_in_viewport",
419 message: format!(
420 "coordinate space `{}` does not match expected `{}`",
421 coordinates.coordinate_space, coordinates.expected_space
422 ),
423 provenance: coordinates.provenance.clone(),
424 });
425 }
426 let inside = coordinates.x >= 0.0
427 && coordinates.y >= 0.0
428 && coordinates.width >= 0.0
429 && coordinates.height >= 0.0
430 && coordinates.x + coordinates.width <= coordinates.viewport_width
431 && coordinates.y + coordinates.height <= coordinates.viewport_height;
432 if inside {
433 Ok(())
434 } else {
435 Err(RuntimeCheckFailure {
436 check: "coordinates_in_viewport",
437 message: format!(
438 "rect ({:.1}, {:.1}, {:.1}, {:.1}) exceeds viewport ({:.1}, {:.1})",
439 coordinates.x,
440 coordinates.y,
441 coordinates.width,
442 coordinates.height,
443 coordinates.viewport_width,
444 coordinates.viewport_height
445 ),
446 provenance: coordinates.provenance.clone(),
447 })
448 }
449}
450
451fn check_finite_number(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
452 let RuntimeValue::Number { value, provenance } = value else {
453 return Err(wrong_kind("finite_number", "number", value));
454 };
455 if value.is_finite() {
456 Ok(())
457 } else {
458 Err(RuntimeCheckFailure {
459 check: "finite_number",
460 message: format!("number `{value}` is NaN or infinite"),
461 provenance: provenance.clone(),
462 })
463 }
464}
465
466fn check_number_range(value: &RuntimeValue, min: i64, max: i64) -> Result<(), RuntimeCheckFailure> {
467 let RuntimeValue::Number { value, provenance } = value else {
468 return Err(wrong_kind("number_in_range", "number", value));
469 };
470 if min > max {
471 return Err(RuntimeCheckFailure {
472 check: "number_in_range",
473 message: format!("invalid check bounds: minimum {min} exceeds maximum {max}"),
474 provenance: provenance.clone(),
475 });
476 }
477 for bound in [min, max] {
489 if (bound as f64) as i128 != i128::from(bound) {
490 return Err(RuntimeCheckFailure {
491 check: "number_in_range",
492 message: format!(
493 "invalid check bounds: {bound} cannot be represented exactly as a number"
494 ),
495 provenance: provenance.clone(),
496 });
497 }
498 }
499 if value.is_finite() && *value >= min as f64 && *value <= max as f64 {
500 Ok(())
501 } else {
502 Err(RuntimeCheckFailure {
503 check: "number_in_range",
504 message: format!("number `{value}` is outside inclusive range {min}..={max}"),
505 provenance: provenance.clone(),
506 })
507 }
508}
509
510fn check_non_empty_text(value: &RuntimeValue) -> Result<(), RuntimeCheckFailure> {
511 let RuntimeValue::Text { value, provenance } = value else {
512 return Err(wrong_kind("non_empty_text", "text", value));
513 };
514 if value.trim().is_empty() {
515 Err(RuntimeCheckFailure {
516 check: "non_empty_text",
517 message: "text is empty or whitespace only".to_owned(),
518 provenance: provenance.clone(),
519 })
520 } else {
521 Ok(())
522 }
523}
524
525fn check_text_length(
526 value: &RuntimeValue,
527 min: usize,
528 max: usize,
529) -> Result<(), RuntimeCheckFailure> {
530 let RuntimeValue::Text { value, provenance } = value else {
531 return Err(wrong_kind("text_length", "text", value));
532 };
533 let length = value.chars().count();
534 if min <= max && (min..=max).contains(&length) {
535 Ok(())
536 } else {
537 Err(RuntimeCheckFailure {
538 check: "text_length",
539 message: if min > max {
540 format!("invalid check bounds: minimum {min} exceeds maximum {max}")
541 } else {
542 format!("text length {length} is outside inclusive range {min}..={max}")
543 },
544 provenance: provenance.clone(),
545 })
546 }
547}
548
549#[cfg(test)]
550#[path = "runtime_checks_tests.rs"]
551mod runtime_checks_tests;