use std::collections::HashMap;
use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
use crate::eval::{
eval_binary, eval_unary, measure_value, value_kind_name, Accountant, BudgetCx, BudgetKind,
Engine, EvalError,
};
use crate::heal::{best_key_match, coerce_string_to, normalized_levenshtein, sentinel_meaning};
use crate::ir::{AnnotationKind, Module, Node, NodeId, SchemaField, Value};
use crate::parser::PathSeg;
use crate::types::ActiveType;
impl Engine {
pub(crate) fn validate_schema(
&self,
module: &Module,
values: &HashMap<NodeId, Value>,
result: &mut Value,
diagnostics: &mut Vec<Diagnostic>,
cx: BudgetCx<'_>,
) -> Result<(), EvalError> {
let Some(schema_id) = module.schema else {
return Ok(());
};
let mut validator = Validator {
module,
values,
diagnostics: Vec::new(),
regexes: HashMap::new(),
schema_bugs: std::collections::HashSet::new(),
resilient: self.resilient,
fills: Vec::new(),
renames: Vec::new(),
coercions: Vec::new(),
deadline: cx.deadline,
wall_limit: cx.wall_limit,
ticks: 0,
account: cx.account,
};
validator.validate_node(schema_id, module.root, result, &mut Vec::new())?;
if self.resilient {
for _ in 0..1024 {
if validator.renames.is_empty()
&& validator.coercions.is_empty()
&& validator.fills.is_empty()
{
break;
}
validator.apply_renames(result)?;
validator.apply_coercions(result)?;
validator.apply_fills(result)?;
}
for req in std::mem::take(&mut validator.renames) {
validator.rename_fallback(&req);
}
for req in std::mem::take(&mut validator.coercions) {
validator.coercion_fallback(&req);
}
for fill in std::mem::take(&mut validator.fills) {
let path = render_path(&fill.segments);
validator.push(
"T004",
format!(
"schema violation at '{}': missing required key (schema default could not be applied)",
display_path(&path)
),
&path,
(fill.span, fill.file),
);
}
}
if !validator.diagnostics.is_empty() && !self.resilient {
if let Some(end) = cx.deadline {
if std::time::Instant::now() >= end {
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
cx.wall_limit,
module,
None,
));
}
}
return Err(EvalError::ValidationFailed {
diagnostics: validator.diagnostics,
});
}
diagnostics.extend(validator.diagnostics);
Ok(())
}
pub(crate) fn validate_annotations(
&self,
module: &Module,
values: &HashMap<NodeId, Value>,
result: &Value,
diagnostics: &mut Vec<Diagnostic>,
deadline: Option<std::time::Instant>,
wall_limit: u64,
) -> Result<(), EvalError> {
let mut items: Vec<(NodeId, &crate::ir::Annotation)> = module
.annotations
.iter()
.flat_map(|(id, anns)| anns.iter().map(|a| (*id, a)))
.collect();
items.sort_by(|a, b| a.1.path.cmp(&b.1.path).then_with(|| a.0 .0.cmp(&b.0 .0)));
let bindings: std::collections::HashSet<NodeId> =
module.bindings.values().copied().collect();
let mut overlay = values.clone();
if let Some(root) = module.root {
overlay_values(module, root, result, &mut overlay);
}
let mut local: Vec<Diagnostic> = Vec::new();
for (node_id, ann) in items {
if let Some(end) = deadline {
if std::time::Instant::now() >= end {
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
wall_limit,
module,
Some(node_id),
));
}
}
let AnnotationKind::Constraint { source, expr } = &ann.kind else {
continue;
};
let value = lookup_result_path(result, &ann.path).or_else(|| {
bindings
.contains(&node_id)
.then(|| values.get(&node_id))
.flatten()
});
let Some(value) = value else {
continue;
};
let span = module
.node_spans
.get(&node_id)
.cloned()
.or(Some(ann.span.clone()));
let file = module.node_files.get(&node_id).cloned();
let shown_source = render_source(module, source, *expr);
match predicate_value(module, &overlay, values, *expr) {
Some(Value::Bool(true)) => {}
Some(Value::Bool(false)) => local.push(Diagnostic {
severity: Severity::Error,
code: "T006".into(),
message: format!(
"constraint violation at '{}': {shown_source} does not hold (actual: {})",
display_path(&ann.path),
render_constrained(value),
),
path: Some(ann.path.clone()),
span,
repair: None,
confidence: 1.0,
file,
}),
Some(other) => local.push(Diagnostic {
severity: Severity::Error,
code: "T006".into(),
message: format!(
"constraint violation at '{}': {shown_source} must evaluate to a boolean, got {}",
display_path(&ann.path),
value_kind_name(&other),
),
path: Some(ann.path.clone()),
span,
repair: None,
confidence: 1.0,
file,
}),
None => {}
}
}
if !local.is_empty() && !self.resilient {
if let Some(end) = deadline {
if std::time::Instant::now() >= end {
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
wall_limit,
module,
None,
));
}
}
return Err(EvalError::ValidationFailed { diagnostics: local });
}
diagnostics.extend(local);
Ok(())
}
}
struct FillRequest {
segments: Vec<PathSeg>,
field_id: NodeId,
default_id: NodeId,
span: Option<Span>,
file: Option<String>,
}
struct RenameRequest {
segments: Vec<PathSeg>,
from: String,
to: String,
field_id: NodeId,
value_id: Option<NodeId>,
span: Option<Span>,
file: Option<String>,
confidence: f64,
}
struct CoerceRequest {
segments: Vec<PathSeg>,
field_id: NodeId,
value_id: Option<NodeId>,
coerced: Value,
span: Option<Span>,
file: Option<String>,
}
struct Validator<'a> {
module: &'a Module,
values: &'a HashMap<NodeId, Value>,
diagnostics: Vec<Diagnostic>,
regexes: HashMap<NodeId, Option<regex::Regex>>,
schema_bugs: std::collections::HashSet<(NodeId, String)>,
resilient: bool,
fills: Vec<FillRequest>,
renames: Vec<RenameRequest>,
coercions: Vec<CoerceRequest>,
deadline: Option<std::time::Instant>,
wall_limit: u64,
ticks: u64,
account: &'a mut Accountant,
}
impl<'a> Validator<'a> {
fn validate_node(
&mut self,
schema_id: NodeId,
value_id: Option<NodeId>,
value: &Value,
segments: &mut Vec<PathSeg>,
) -> Result<(), EvalError> {
self.tick(schema_id)?;
match self.module.get(schema_id).cloned() {
Some(Node::SchemaField { field }) => {
self.validate_field(schema_id, &field, value_id, value, segments);
}
Some(Node::SchemaTable { fields, closed }) => {
self.validate_table(schema_id, &fields, closed, value_id, value, segments)?;
}
Some(Node::SchemaArray { element }) => {
self.validate_array(schema_id, element, value_id, value, segments)?;
}
_ => {}
}
Ok(())
}
fn tick(&mut self, node: NodeId) -> Result<(), EvalError> {
self.ticks += 1;
if self.ticks % 64 != 0 {
return Ok(());
}
if let Some(end) = self.deadline {
if std::time::Instant::now() >= end {
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
self.wall_limit,
self.module,
Some(node),
));
}
}
Ok(())
}
fn validate_table(
&mut self,
schema_id: NodeId,
fields: &indexmap::IndexMap<String, NodeId>,
closed: bool,
value_id: Option<NodeId>,
value: &Value,
segments: &mut Vec<PathSeg>,
) -> Result<(), EvalError> {
let Value::Table(map) = value else {
let path = render_path(segments);
self.push(
"T002",
format!(
"schema violation at '{}': expected Table, got {}",
display_path(&path),
value_kind_name(value)
),
&path,
self.spans(value_id, schema_id),
);
return Ok(());
};
let arena_children = match value_id.and_then(|id| self.module.get(id)) {
Some(Node::Table { fields }) => Some(fields.clone()),
_ => None,
};
for (name, field_id) in fields {
segments.push(PathSeg::Key(name.clone()));
match map.get(name) {
None => self.check_missing(*field_id, segments),
Some(v) => {
let child_id = arena_children
.as_ref()
.and_then(|children| children.get(name).copied());
self.validate_node(*field_id, child_id, v, segments)?;
}
}
segments.pop();
}
if closed {
let legal: Vec<String> = fields.keys().cloned().collect();
for (key, value) in map {
if fields.contains_key(key) {
continue;
}
segments.push(PathSeg::Key(key.clone()));
let key_path = render_path(segments);
let key_id = arena_children
.as_ref()
.and_then(|children| children.get(key).copied());
let spans = self.spans(key_id, schema_id);
let suggestion = best_key_match(key, &legal, thresholds::MEDIUM);
let candidate: Option<(NodeId, bool)> = match &suggestion {
Some((suggested, score))
if *score >= thresholds::HIGH && map.get(suggested).is_none() =>
{
match fields.get(suggested) {
Some(field_id) => match self.module.get(*field_id) {
Some(Node::SchemaField { field }) => {
Some((*field_id, type_accepts(&field.typ, value)))
}
Some(Node::SchemaTable { .. }) => {
Some((*field_id, matches!(value, Value::Table(_))))
}
Some(Node::SchemaArray { .. }) => {
Some((*field_id, matches!(value, Value::Array(_))))
}
_ => None,
},
None => None,
}
}
_ => None,
};
let mut renamed = false;
if self.resilient {
if let (Some((field_id, true)), Some((suggested, score))) =
(candidate, &suggestion)
{
self.renames.push(RenameRequest {
segments: segments.clone(),
from: key.clone(),
to: suggested.clone(),
field_id,
value_id: key_id,
span: spans.0.clone(),
file: spans.1.clone(),
confidence: *score,
});
renamed = true;
}
}
if !renamed {
let mut message = format!(
"schema violation at '{}': unexpected key '{key}' in closed table",
display_path(&key_path)
);
let mut repair = None;
let mut confidence = 1.0;
if let Some((suggested, score)) = &suggestion {
message.push_str(&format!("; did you mean '{suggested}'?"));
repair = Some(Repair {
original: key.clone(),
suggested: suggested.clone(),
kind: RepairKind::KeyTypo,
safe: normalized_levenshtein(key, suggested) >= thresholds::AUTO_APPLY,
});
confidence = *score;
}
self.push_diag(
"T005",
message,
&key_path,
spans,
DiagMeta {
severity: Severity::Error,
repair,
confidence,
},
);
}
segments.pop();
}
}
Ok(())
}
fn validate_array(
&mut self,
schema_id: NodeId,
element: NodeId,
value_id: Option<NodeId>,
value: &Value,
segments: &mut Vec<PathSeg>,
) -> Result<(), EvalError> {
let Value::Array(elements) = value else {
let path = render_path(segments);
self.push(
"T002",
format!(
"schema violation at '{}': expected Array, got {}",
display_path(&path),
value_kind_name(value)
),
&path,
self.spans(value_id, schema_id),
);
return Ok(());
};
let arena_elements: Option<Vec<NodeId>> = match value_id.and_then(|id| self.module.get(id))
{
Some(Node::Array { elements: arena }) if arena.len() == elements.len() => {
Some(arena.clone())
}
_ => None,
};
for (i, v) in elements.iter().enumerate() {
let elem_id = arena_elements.as_ref().and_then(|els| els.get(i).copied());
segments.push(PathSeg::Index(i));
self.validate_node(element, elem_id, v, segments)?;
segments.pop();
}
Ok(())
}
fn validate_field(
&mut self,
schema_id: NodeId,
field: &SchemaField,
value_id: Option<NodeId>,
value: &Value,
segments: &mut [PathSeg],
) {
let path = render_path(segments);
if !type_accepts(&field.typ, value) {
if let Value::String(text) = value {
if let Some(coerced) = coerce_string_to(text, &field.typ) {
let spans = self.spans(value_id, schema_id);
if self.resilient {
self.coercions.push(CoerceRequest {
segments: segments.to_vec(),
field_id: schema_id,
value_id,
coerced,
span: spans.0,
file: spans.1,
});
} else {
self.push(
"T002",
format!(
"schema violation at '{}': expected {}, got {} (coercible under --resilient)",
display_path(&path),
field.typ.name(),
value_kind_name(value)
),
&path,
spans,
);
}
return;
}
if let Some(intent) = sentinel_meaning(text) {
self.push(
"T002",
format!(
"schema violation at '{}': expected {}, got {} (sentinel '{}' signals {} but the schema designates no value — set an explicit value or a schema default)",
display_path(&path),
field.typ.name(),
value_kind_name(value),
text.to_lowercase(),
intent,
),
&path,
self.spans(value_id, schema_id),
);
return;
}
}
self.push(
"T002",
format!(
"schema violation at '{}': expected {}, got {}",
display_path(&path),
field.typ.name(),
value_kind_name(value)
),
&path,
self.spans(value_id, schema_id),
);
return;
}
self.check_constraints(schema_id, field, value, &path, value_id);
}
fn check_missing(&mut self, field_id: NodeId, segments: &[PathSeg]) {
let (default_arg, is_null, field_span) = match self.module.get(field_id) {
Some(Node::SchemaField { field }) => (
field.default,
matches!(field.typ, ActiveType::Null),
field.span.clone(),
),
_ => (None, false, None),
};
let path = render_path(segments);
let (span, file) =
match field_span.or_else(|| self.module.node_spans.get(&field_id).cloned()) {
Some(s) => (Some(s), self.module.node_files.get(&field_id).cloned()),
None => (None, None),
};
if let Some(default_id) = default_arg {
if self.resilient {
self.fills.push(FillRequest {
segments: segments.to_vec(),
field_id,
default_id,
span,
file,
});
return;
}
self.push(
"T004",
format!(
"schema violation at '{}': missing required key (strict mode requires explicit values despite a schema default)",
display_path(&path)
),
&path,
(span, file),
);
return;
}
if is_null {
return;
}
self.push(
"T004",
format!(
"schema violation at '{}': missing required key (no default)",
display_path(&path)
),
&path,
(span, file),
);
}
fn apply_fills(&mut self, result: &mut Value) -> Result<(), EvalError> {
let fills = std::mem::take(&mut self.fills);
for mut fill in fills {
if value_at(result, &fill.segments).is_some() {
continue;
}
let Some(default_value) = self.values.get(&fill.default_id).cloned() else {
self.push(
"T004",
format!(
"schema violation at '{}': missing required key (schema default could not be applied)",
display_path(&render_path(&fill.segments))
),
&render_path(&fill.segments),
(fill.span.clone(), fill.file.clone()),
);
continue;
};
let fill_size = measure_value(&default_value);
self.account.charge(self.module, None, fill_size)?;
if !insert_at(result, &fill.segments, default_value.clone()) {
self.push(
"T004",
format!(
"schema violation at '{}': missing required key (schema default could not be applied)",
display_path(&render_path(&fill.segments))
),
&render_path(&fill.segments),
(fill.span.clone(), fill.file.clone()),
);
continue;
};
let path = render_path(&fill.segments);
self.diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W005".into(),
message: format!(
"filled missing key '{}' from schema default",
display_path(&path)
),
path: Some(path),
span: fill.span,
repair: None,
confidence: 1.0,
file: fill.file,
});
self.validate_node(
fill.field_id,
Some(fill.default_id),
&default_value,
&mut fill.segments,
)?;
}
Ok(())
}
fn apply_renames(&mut self, result: &mut Value) -> Result<(), EvalError> {
let mut passes = 0u32;
while !self.renames.is_empty() {
passes += 1;
if passes > 1024 {
let pending = std::mem::take(&mut self.renames);
for req in pending {
let RenameRequest {
segments,
from,
to,
span,
file,
confidence,
..
} = req;
let path = render_path(&segments);
let safe = normalized_levenshtein(&from, &to) >= thresholds::AUTO_APPLY;
self.push_diag(
"T005",
format!(
"schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
display_path(&path),
from,
to
),
&path,
(span, file),
DiagMeta {
severity: Severity::Error,
repair: Some(Repair {
original: from,
suggested: to,
kind: RepairKind::KeyTypo,
safe,
}),
confidence,
},
);
}
break;
}
let pending = std::mem::take(&mut self.renames);
for req in pending {
let Some((last, parent_segs)) = req.segments.split_last() else {
continue;
};
if !matches!(last, PathSeg::Key(key) if key == &req.from) {
continue;
}
let Some(table) = value_at_mut(result, parent_segs) else {
self.rename_fallback(&req);
continue;
};
let Value::Table(map) = table else {
self.rename_fallback(&req);
continue;
};
if map.contains_key(&req.to) {
self.rename_fallback(&req);
continue;
}
let Some(moved) = map.shift_remove(&req.from) else {
self.rename_fallback(&req);
continue;
};
map.insert(req.to.clone(), moved);
let mut fixed: Vec<PathSeg> = parent_segs
.iter()
.cloned()
.chain(std::iter::once(PathSeg::Key(req.to.clone())))
.collect();
let fixed_path = render_path(&fixed);
self.diagnostics.retain(|d| {
!(d.code == "T004" && d.path.as_deref() == Some(fixed_path.as_str()))
});
self.fills
.retain(|f| render_path(&f.segments) != fixed_path);
self.push_diag(
"H005",
format!(
"renamed key '{}' to '{}' (confidence {:.2})",
req.from, req.to, req.confidence
),
&fixed_path,
(req.span.clone(), req.file.clone()),
DiagMeta {
severity: Severity::Warning,
repair: Some(Repair {
original: req.from.clone(),
suggested: req.to.clone(),
kind: RepairKind::KeyTypo,
safe: normalized_levenshtein(&req.from, &req.to)
>= thresholds::AUTO_APPLY,
}),
confidence: req.confidence,
},
);
if let Some(current) = value_at(result, &fixed).cloned() {
self.validate_node(req.field_id, req.value_id, ¤t, &mut fixed)?;
}
}
}
Ok(())
}
fn rename_fallback(&mut self, req: &RenameRequest) {
let path = render_path(&req.segments);
self.push_diag(
"T005",
format!(
"schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
display_path(&path),
req.from,
req.to
),
&path,
(req.span.clone(), req.file.clone()),
DiagMeta {
severity: Severity::Error,
repair: Some(Repair {
original: req.from.clone(),
suggested: req.to.clone(),
kind: RepairKind::KeyTypo,
safe: normalized_levenshtein(&req.from, &req.to) >= thresholds::AUTO_APPLY,
}),
confidence: req.confidence,
},
);
}
fn coercion_fallback(&mut self, req: &CoerceRequest) {
let path = render_path(&req.segments);
let expected = match self.module.get(req.field_id) {
Some(Node::SchemaField { field }) => field.typ.name().to_string(),
_ => "schema type".to_string(),
};
self.push(
"T002",
format!(
"schema violation at '{}': expected {}, got string (coercion could not be applied)",
display_path(&path),
expected
),
&path,
(req.span.clone(), req.file.clone()),
);
}
fn apply_coercions(&mut self, result: &mut Value) -> Result<(), EvalError> {
let pending = std::mem::take(&mut self.coercions);
for req in pending {
if !insert_at(result, &req.segments, req.coerced.clone()) {
self.coercion_fallback(&req);
continue;
}
let path = render_path(&req.segments);
self.push_diag(
"H007",
format!(
"resilient: coerced value at '{}' from string to {}",
display_path(&path),
value_kind_name(&req.coerced)
),
&path,
(req.span.clone(), req.file.clone()),
DiagMeta {
severity: Severity::Warning,
repair: None,
confidence: 0.90,
},
);
if let Some(current) = value_at(result, &req.segments).cloned() {
let mut segs = req.segments.clone();
self.validate_node(req.field_id, req.value_id, ¤t, &mut segs)?;
}
}
Ok(())
}
fn check_constraints(
&mut self,
schema_id: NodeId,
field: &SchemaField,
value: &Value,
path: &str,
value_id: Option<NodeId>,
) {
let spans = self.spans(value_id, schema_id);
match (&field.typ, value) {
(ActiveType::Int, Value::Int(n)) => {
self.check_int_bounds(field, schema_id, *n, path, spans, true);
}
(ActiveType::Float, Value::Float(f)) => {
self.check_float_bounds(field, schema_id, *f, path, spans);
}
(ActiveType::Float, Value::Int(n)) => {
self.check_float_bounds(field, schema_id, *n as f64, path, spans);
}
(ActiveType::Duration, Value::DurationMs(ms)) => {
self.check_duration_bounds(field, schema_id, *ms, path, spans);
}
(ActiveType::Bytes, Value::Int(n)) => {
self.check_int_bounds(field, schema_id, *n, path, spans, false);
}
(ActiveType::String, Value::String(s)) => {
self.check_string_bounds(field, schema_id, s, path, spans);
}
(ActiveType::Enum(members), Value::String(s)) if !members.iter().any(|m| m == s) => {
let quoted: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
self.push(
"T003",
format!(
"schema violation at '{}': not a member of Enum({})",
display_path(path),
quoted.join(", ")
),
path,
spans,
);
}
_ => {}
}
}
fn check_int_bounds(
&mut self,
field: &SchemaField,
field_id: NodeId,
n: i64,
path: &str,
spans: (Option<Span>, Option<String>),
allow_float_bounds: bool,
) {
for (arg, name) in [(field.min, "min"), (field.max, "max")] {
let Some(arg_id) = arg else { continue };
let Some(bound) = self.values.get(&arg_id) else {
continue;
};
let (numerically_less, numerically_greater) = match bound {
Value::Int(b) => (n < *b, n > *b),
Value::Float(b) if allow_float_bounds => ((n as f64) < *b, (n as f64) > *b),
_ => {
if self.bug_once(field_id, name) {
self.violation(
path,
&format!("incomparable {name} bound for {} fields", field.typ.name()),
spans.clone(),
);
}
continue;
}
};
if (name == "min" && numerically_less) || (name == "max" && numerically_greater) {
self.violation(
path,
if name == "min" {
"below minimum"
} else {
"above maximum"
},
spans.clone(),
);
}
}
}
fn bug_once(&mut self, field_id: NodeId, arg: &str) -> bool {
self.schema_bugs.insert((field_id, arg.to_string()))
}
fn check_float_bounds(
&mut self,
field: &SchemaField,
field_id: NodeId,
f: f64,
path: &str,
spans: (Option<Span>, Option<String>),
) {
for (arg, name) in [(field.min, "min"), (field.max, "max")] {
let Some(arg_id) = arg else { continue };
let Some(bound) = self.values.get(&arg_id) else {
continue;
};
let bound = match bound {
Value::Int(n) => *n as f64,
Value::Float(x) => *x,
_ => {
if self.bug_once(field_id, name) {
self.violation(
path,
&format!("incomparable {name} bound for Float fields"),
spans.clone(),
);
}
continue;
}
};
if (name == "min" && f < bound) || (name == "max" && f > bound) {
self.violation(
path,
if name == "min" {
"below minimum"
} else {
"above maximum"
},
spans.clone(),
);
}
}
}
fn check_duration_bounds(
&mut self,
field: &SchemaField,
field_id: NodeId,
ms: u64,
path: &str,
spans: (Option<Span>, Option<String>),
) {
for (arg, name) in [(field.min, "min"), (field.max, "max")] {
let Some(arg_id) = arg else { continue };
let Some(bound) = self.values.get(&arg_id) else {
continue;
};
let bound = match bound {
Value::DurationMs(b) => *b,
Value::Int(b) if *b >= 0 => *b as u64,
_ => {
if self.bug_once(field_id, name) {
self.violation(
path,
&format!("incomparable {name} bound for Duration fields"),
spans.clone(),
);
}
continue;
}
};
if (name == "min" && ms < bound) || (name == "max" && ms > bound) {
self.violation(
path,
if name == "min" {
"below minimum"
} else {
"above maximum"
},
spans.clone(),
);
}
}
}
fn check_string_bounds(
&mut self,
field: &SchemaField,
field_id: NodeId,
s: &str,
path: &str,
spans: (Option<Span>, Option<String>),
) {
let len = s.chars().count();
for (arg, name, detail) in [
(field.min_len, "min_len", "too short"),
(field.max_len, "max_len", "too long"),
] {
let Some(arg_id) = arg else { continue };
let Some(bound) = self.values.get(&arg_id) else {
continue;
};
match bound {
Value::Int(n) => {
let limit = (*n).max(0) as usize;
if (name == "min_len" && len < limit) || (name == "max_len" && len > limit) {
self.violation(path, detail, spans.clone());
}
}
_ => {
if self.bug_once(field_id, name) {
self.violation(
path,
&format!("incomparable {name} bound for String fields"),
spans.clone(),
);
}
}
}
}
if let Some(pattern_id) = field.pattern {
let owned_pattern: Option<String> = match self.values.get(&pattern_id) {
Some(Value::String(pattern)) => Some(pattern.clone()),
Some(_) => {
if self.bug_once(field_id, "pattern") {
self.violation(
path,
"incomparable pattern bound for String fields",
spans.clone(),
);
}
None
}
None => None,
};
if let Some(pattern) = owned_pattern {
let cached = self.regexes.get(&pattern_id).cloned();
let compiled = match cached {
Some(result) => result,
None => {
let result = regex::Regex::new(&pattern).ok();
if result.is_none() && self.bug_once(field_id, "pattern") {
self.violation(path, "invalid pattern in schema", spans.clone());
}
self.regexes.insert(pattern_id, result.clone());
result
}
};
if let Some(re) = compiled {
if !re.is_match(s) {
self.violation(path, "does not match pattern", spans.clone());
}
}
}
}
}
fn spans(&self, value_id: Option<NodeId>, schema_id: NodeId) -> (Option<Span>, Option<String>) {
if let Some(id) = value_id {
if let Some(span) = self.module.node_spans.get(&id).cloned() {
return (Some(span), self.module.node_files.get(&id).cloned());
}
}
(
self.module.node_spans.get(&schema_id).cloned(),
self.module.node_files.get(&schema_id).cloned(),
)
}
fn violation(&mut self, path: &str, detail: &str, spans: (Option<Span>, Option<String>)) {
self.push(
"T003",
format!("schema violation at '{}': {detail}", display_path(path)),
path,
spans,
);
}
fn push(
&mut self,
code: &str,
message: String,
path: &str,
spans: (Option<Span>, Option<String>),
) {
self.push_diag(
code,
message,
path,
spans,
DiagMeta {
severity: Severity::Error,
repair: None,
confidence: 1.0,
},
);
}
fn push_diag(
&mut self,
code: &str,
message: String,
path: &str,
spans: (Option<Span>, Option<String>),
meta: DiagMeta,
) {
let (span, file) = spans;
self.diagnostics.push(Diagnostic {
severity: meta.severity,
code: code.into(),
message,
path: Some(path.to_string()),
span,
repair: meta.repair,
confidence: meta.confidence,
file,
});
}
}
struct DiagMeta {
severity: Severity,
repair: Option<Repair>,
confidence: f64,
}
fn display_path(path: &str) -> &str {
if path.is_empty() {
"<root>"
} else {
path
}
}
fn lookup_result_path<'v>(result: &'v Value, path: &str) -> Option<&'v Value> {
let mut cursor = result;
for segment in path.split('.') {
match cursor {
Value::Table(map) => cursor = map.get(segment)?,
_ => return None,
}
}
Some(cursor)
}
fn overlay_values(
module: &Module,
node_id: NodeId,
value: &Value,
overlay: &mut HashMap<NodeId, Value>,
) {
overlay.insert(node_id, value.clone());
match (module.get(node_id), value) {
(Some(Node::Table { fields }), Value::Table(map)) => {
for (key, child_id) in fields {
match map.get(key) {
Some(child_value) => overlay_values(module, *child_id, child_value, overlay),
None => {
remove_subtree(module, *child_id, overlay);
}
}
}
}
(Some(Node::Array { elements }), Value::Array(values))
if elements.len() == values.len() =>
{
for (child_id, child_value) in elements.iter().zip(values.iter()) {
overlay_values(module, *child_id, child_value, overlay);
}
}
(Some(Node::Array { .. }), Value::Array(_)) => {
remove_subtree(module, node_id, overlay);
overlay.insert(node_id, value.clone());
}
_ => {}
}
}
fn remove_subtree(module: &Module, node_id: NodeId, overlay: &mut HashMap<NodeId, Value>) {
overlay.remove(&node_id);
match module.get(node_id) {
Some(Node::Table { fields }) => {
for child_id in fields.values() {
remove_subtree(module, *child_id, overlay);
}
}
Some(Node::Array { elements }) => {
for child_id in elements {
remove_subtree(module, *child_id, overlay);
}
}
_ => {}
}
}
fn predicate_value(
module: &Module,
overlay: &HashMap<NodeId, Value>,
base: &HashMap<NodeId, Value>,
expr: NodeId,
) -> Option<Value> {
match module.get(expr) {
Some(Node::Literal(v)) => Some(v.clone()),
Some(Node::Ref(target)) => overlay.get(target).cloned(),
Some(Node::Ident(name)) => module
.bindings
.get(name)
.and_then(|target| overlay.get(target))
.cloned(),
Some(Node::Binary { op, lhs, rhs }) => {
let l = predicate_value(module, overlay, base, *lhs)?;
let r = predicate_value(module, overlay, base, *rhs)?;
eval_binary(*op, &l, &r).ok()
}
Some(Node::Unary { op, expr }) => {
let v = predicate_value(module, overlay, base, *expr)?;
eval_unary(*op, &v).ok()
}
_ => overlay
.get(&expr)
.cloned()
.or_else(|| base.get(&expr).cloned()),
}
}
fn render_source(module: &Module, source: &str, expr: NodeId) -> String {
if subtree_has_secret(module, expr) {
return "constraint".to_string();
}
truncate_120(source)
}
fn subtree_has_secret(module: &Module, expr: NodeId) -> bool {
let mut stack = vec![expr];
let mut seen = std::collections::HashSet::new();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
match module.get(id) {
None => {}
Some(Node::Secret { .. }) => return true,
Some(Node::Ref(target)) => stack.push(*target),
Some(Node::Ident(name)) => {
if let Some(target) = module.bindings.get(name) {
stack.push(*target);
}
}
Some(Node::Binary { lhs, rhs, .. }) => stack.extend([*lhs, *rhs]),
Some(Node::Unary { expr, .. }) => stack.push(*expr),
Some(Node::Array { elements }) => stack.extend(elements.iter().copied()),
Some(Node::Table { fields }) => stack.extend(fields.values().copied()),
Some(Node::Interpolate { parts }) => {
for part in parts {
if let crate::ir::InterpPart::Expr(e) = part {
stack.push(*e);
}
}
}
Some(Node::Semantic { inner, .. }) => stack.push(*inner),
Some(Node::Env {
default: Some(def), ..
}) => stack.push(*def),
_ => {}
}
}
false
}
fn render_constrained(value: &Value) -> String {
let text = match value {
Value::Secret(_) => "secret".to_string(),
Value::String(s) => format!("{s:?}"),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
Value::DurationMs(ms) => format!("{ms}ms"),
Value::Quantity(n, unit) => format!("{n}{}", unit.suffix()),
Value::Array(_) => "array".to_string(),
Value::Table(_) => "table".to_string(),
};
truncate_120(&text)
}
fn truncate_120(text: &str) -> String {
const LIMIT: usize = 120;
if text.chars().count() > LIMIT {
format!("{}…", text.chars().take(LIMIT - 1).collect::<String>())
} else {
text.to_string()
}
}
fn render_path(segments: &[PathSeg]) -> String {
let mut out = String::new();
for segment in segments {
match segment {
PathSeg::Key(key) => {
if !out.is_empty() {
out.push('.');
}
out.push_str(key);
}
PathSeg::Index(i) => {
out.push_str(&format!("[{i}]"));
}
}
}
out
}
fn value_at<'v>(result: &'v Value, segments: &[PathSeg]) -> Option<&'v Value> {
let mut cursor = result;
for segment in segments {
match (segment, cursor) {
(PathSeg::Key(key), Value::Table(map)) => cursor = map.get(key)?,
(PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get(*i)?,
_ => return None,
}
}
Some(cursor)
}
fn value_at_mut<'v>(result: &'v mut Value, segments: &[PathSeg]) -> Option<&'v mut Value> {
let mut cursor = result;
for segment in segments {
match (segment, cursor) {
(PathSeg::Key(key), Value::Table(map)) => cursor = map.get_mut(key)?,
(PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get_mut(*i)?,
_ => return None,
}
}
Some(cursor)
}
fn insert_at(result: &mut Value, segments: &[PathSeg], value: Value) -> bool {
let Some((first, rest)) = segments.split_first() else {
return false;
};
if rest.is_empty() {
if let (PathSeg::Key(key), Value::Table(map)) = (first, result) {
map.insert(key.clone(), value);
return true;
}
return false;
}
match (first, result) {
(PathSeg::Key(key), Value::Table(map)) => match map.get_mut(key) {
Some(next) => insert_at(next, rest, value),
None => false,
},
(PathSeg::Index(i), Value::Array(elements)) => match elements.get_mut(*i) {
Some(next) => insert_at(next, rest, value),
None => false,
},
_ => false,
}
}
fn type_accepts(typ: &ActiveType, value: &Value) -> bool {
use ActiveType::*;
use Value::Table as Tab;
matches!(
(typ, value),
(Null, Value::Null)
| (Bool, Value::Bool(_))
| (Int, Value::Int(_))
| (Float, Value::Float(_))
| (Float, Value::Int(_))
| (String, Value::String(_))
| (Duration, Value::DurationMs(_))
| (Bytes, Value::Int(_))
| (Secret, Value::Secret(_))
| (Any, _)
| (Table { .. }, Tab(_))
| (Enum(_), Value::String(_))
)
}