Add required array element constraints

This commit is contained in:
2026-08-11 17:24:04 +09:00
parent 5a05da6fe4
commit 46b2b3b1b6
35 changed files with 6328 additions and 5042 deletions
+3
View File
@@ -50,6 +50,9 @@ pub enum Expr {
Ident(String),
Object(Vec<Field>),
Array(Vec<ExprId>),
ArrayConstraint {
item: ExprId,
},
Let {
bindings: Vec<Field>,
body: ExprId,
+37 -4
View File
@@ -11,6 +11,7 @@ pub fn normalize_constraints(
span: Span,
) -> crate::Result<Vec<ConstraintEntry>> {
let mut primitive: Option<(PrimitiveType, Span)> = None;
let mut array_span: Option<Span> = None;
let mut lower: Option<(Bound, Span)> = None;
let mut upper: Option<(Bound, Span)> = None;
let mut rest = Vec::new();
@@ -61,6 +62,13 @@ pub fn normalize_constraints(
constraint: Constraint::Regex(pattern),
span: entry.span,
}),
Constraint::ArrayItems(item) => {
array_span.get_or_insert(entry.span);
rest.push(ConstraintEntry {
constraint: Constraint::ArrayItems(item),
span: entry.span,
});
}
Constraint::BuiltinPredicate(name) => rest.push(ConstraintEntry {
constraint: Constraint::BuiltinPredicate(name),
span: entry.span,
@@ -68,12 +76,37 @@ pub fn normalize_constraints(
}
}
if let (Some((_, primitive_span)), Some(array_span)) = (primitive, array_span) {
return Err(Diagnostic::new(
DiagnosticKind::Conflict,
span,
"array element constraints conflict with primitive type constraints",
)
.with_label(primitive_span, "primitive type constraint")
.with_label(array_span, "array element constraint"));
}
if let Some(array_span) = array_span
&& (lower.is_some() || upper.is_some())
{
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
span,
"numeric comparison constraints conflict with array element constraints",
)
.with_label(array_span, "array element constraint");
if let Some((_, lower_span)) = lower {
diagnostic = diagnostic.with_label(lower_span, "numeric comparison constraint");
}
if let Some((_, upper_span)) = upper {
diagnostic = diagnostic.with_label(upper_span, "numeric comparison constraint");
}
return Err(diagnostic);
}
if matches!(
primitive,
Some((
PrimitiveType::String | PrimitiveType::Bool | PrimitiveType::Array,
_
))
Some((PrimitiveType::String | PrimitiveType::Bool, _))
) && (lower.is_some() || upper.is_some())
{
let mut diagnostic = Diagnostic::new(
+28 -2
View File
@@ -10,6 +10,11 @@ pub enum HostValue {
Float(f64),
Bool(bool),
Array(Vec<HostValue>),
ArrayConstraint {
item: Box<HostValue>,
constraints: Vec<Constraint>,
default: Option<Box<HostValue>>,
},
Object(Vec<HostField>),
Abstract {
constraints: Vec<Constraint>,
@@ -91,8 +96,12 @@ impl HostValue {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Bool))
}
pub fn array_type() -> Self {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Array))
pub fn array_of(item: HostValue) -> Self {
Self::ArrayConstraint {
item: Box::new(item),
constraints: Vec::new(),
default: None,
}
}
pub fn builtin_predicate(name: impl Into<String>) -> Self {
@@ -109,6 +118,7 @@ impl HostValue {
pub fn with_constraint(mut self, constraint: Constraint) -> Self {
match &mut self {
Self::Abstract { constraints, .. } => constraints.push(constraint),
Self::ArrayConstraint { constraints, .. } => constraints.push(constraint),
_ => {
self = Self::Abstract {
constraints: alloc::vec![constraint],
@@ -143,6 +153,22 @@ impl HostValue {
pub fn default(self, value: HostValue) -> Result<Self> {
match self {
Self::ArrayConstraint {
item,
constraints,
default: None,
} => Ok(Self::ArrayConstraint {
item,
constraints,
default: Some(Box::new(value)),
}),
Self::ArrayConstraint {
default: Some(_), ..
} => Err(Diagnostic::new(
DiagnosticKind::DefaultConflict,
Span::default(),
"host value already has a default",
)),
Self::Abstract {
constraints,
default: None,
+234 -32
View File
@@ -111,9 +111,12 @@ impl<L: SourceLoader> Engine<L> {
ConcreteValue::Bool(value) => Ok(Data::Bool(*value)),
ConcreteValue::Array(items) => {
let mut data = Vec::new();
for item in items {
for (index, item) in items.iter().enumerate() {
let value = self.force(*item)?;
data.push(self.materialize_with_path(&value, path)?);
path.push(format!("[{index}]"));
let value = self.materialize_with_path(&value, path);
path.pop();
data.push(value?);
}
Ok(Data::Array(data))
}
@@ -158,13 +161,14 @@ impl<L: SourceLoader> Engine<L> {
};
let default_span = self.thunk_span(default);
let value = self.force(default)?;
self.ensure_satisfies(
&value,
&abstract_value.constraints,
default_span,
Some((default_span, "default value checked here")),
)
.map_err(|diag| self.with_path_context(diag, "materializing", path))?;
let value = self
.apply_constraints(
value,
&abstract_value.constraints,
default_span,
Some((default_span, "default value checked here")),
)
.map_err(|diag| self.with_path_context(diag, "materializing", path))?;
self.materialize_with_path(&value, path)
}
}
@@ -262,6 +266,22 @@ impl<L: SourceLoader> Engine<L> {
.collect();
Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
}
Expr::ArrayConstraint { item } => {
let item = self.add_expr_thunk(
ExprRef {
module: reference.module,
expr: item,
},
env,
);
Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![ConstraintEntry {
constraint: Constraint::ArrayItems(item),
span,
}],
default: None,
}))
}
Expr::Let { bindings, body } => {
let let_env = self.new_env(Some(env));
for binding in bindings {
@@ -550,6 +570,13 @@ impl<L: SourceLoader> Engine<L> {
if let Some(thunk) = self.lookup(env, name) {
return self.force(thunk);
}
if name == "Array" {
return Err(Diagnostic::new(
DiagnosticKind::UnresolvedIdentifier,
span,
"`Array` is not a valid constraint; use an element constraint such as `[...String]`",
));
}
if name.chars().next().is_some_and(char::is_uppercase) {
return Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![ConstraintEntry {
@@ -697,7 +724,10 @@ impl<L: SourceLoader> Engine<L> {
) -> Result<bool> {
match self.expr(pattern).clone() {
Expr::Wildcard => Ok(true),
Expr::CompareConstraint { .. } | Expr::RegexConstraint(_) | Expr::Ident(_) => {
Expr::ArrayConstraint { .. }
| Expr::CompareConstraint { .. }
| Expr::RegexConstraint(_)
| Expr::Ident(_) => {
let constraint = self.eval_expr(pattern, env)?;
match constraint {
RuntimeValue::Abstract(abstract_value) => self
@@ -738,13 +768,12 @@ impl<L: SourceLoader> Engine<L> {
}
(RuntimeValue::Abstract(abstract_value), concrete @ RuntimeValue::Concrete(_))
| (concrete @ RuntimeValue::Concrete(_), RuntimeValue::Abstract(abstract_value)) => {
self.ensure_satisfies(
&concrete,
self.apply_constraints(
concrete,
&abstract_value.constraints,
span,
Some((span, "concrete value being composed here")),
)?;
Ok(concrete)
)
}
(
RuntimeValue::Concrete(ConcreteValue::Object(lhs)),
@@ -830,24 +859,35 @@ impl<L: SourceLoader> Engine<L> {
span: Span,
value_label: Option<(Span, &'static str)>,
) -> Result<()> {
for constraint in constraints {
self.satisfies(value, constraint, span, value_label)?;
}
Ok(())
self.apply_constraints(value.clone(), constraints, span, value_label)
.map(|_| ())
}
fn satisfies(
fn apply_constraints(
&mut self,
value: &RuntimeValue,
mut value: RuntimeValue,
constraints: &[ConstraintEntry],
span: Span,
value_label: Option<(Span, &'static str)>,
) -> Result<RuntimeValue> {
for constraint in constraints {
value = self.apply_constraint(value, constraint, span, value_label)?;
}
Ok(value)
}
fn apply_constraint(
&mut self,
value: RuntimeValue,
constraint: &ConstraintEntry,
span: Span,
value_label: Option<(Span, &'static str)>,
) -> Result<()> {
) -> Result<RuntimeValue> {
let constraint_value = &constraint.constraint;
match constraint_value {
Constraint::Type(primitive) => {
if value_matches_primitive(value, *primitive) {
Ok(())
if value_matches_primitive(&value, *primitive) {
Ok(value)
} else {
Err(self.constraint_violation(
constraint,
@@ -857,8 +897,41 @@ impl<L: SourceLoader> Engine<L> {
))
}
}
Constraint::Compare(op, expected) => compare_value(value, *op, expected)
.then_some(())
Constraint::ArrayItems(item_constraint) => {
let RuntimeValue::Concrete(ConcreteValue::Array(items)) = value else {
return Err(self.constraint_violation(
constraint,
span,
value_label,
"value does not satisfy array element constraint",
));
};
let item_constraint_value = self.force(*item_constraint)?;
let mut constrained_items = Vec::with_capacity(items.len());
for (index, item) in items.into_iter().enumerate() {
let item_span = self.thunk_span(item);
let item_value = self.force(item)?;
let constrained = self
.compose_and(item_constraint_value.clone(), item_value, item_span)
.map_err(|diagnostic| {
diagnostic
.with_label(
constraint.span,
"array element constraint declared here",
)
.with_label(
item_span,
format!("array element [{index}] checked here"),
)
})?;
constrained_items.push(self.add_value_thunk_with_span(constrained, item_span));
}
Ok(RuntimeValue::Concrete(ConcreteValue::Array(
constrained_items,
)))
}
Constraint::Compare(op, expected) => compare_value(&value, *op, expected)
.then_some(value)
.ok_or_else(|| {
self.constraint_violation(
constraint,
@@ -868,14 +941,15 @@ impl<L: SourceLoader> Engine<L> {
)
}),
Constraint::Regex(pattern) => {
satisfies_regex(value, pattern, constraint.span).map_err(|diag| {
satisfies_regex(&value, pattern, constraint.span).map_err(|diag| {
let diag = diag.with_label(constraint.span, "regex constraint declared here");
if let Some((span, message)) = value_label {
diag.with_label(span, message)
} else {
diag
}
})
})?;
Ok(value)
}
Constraint::BuiltinPredicate(name) => Err(Diagnostic::new(
DiagnosticKind::UnsupportedFeature,
@@ -940,6 +1014,33 @@ impl<L: SourceLoader> Engine<L> {
}
Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
}
HostValue::ArrayConstraint {
item,
mut constraints,
default,
} => {
let item = self.internalize_host_value(*item)?;
let item = self.add_value_thunk(item);
let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?;
Some(self.add_value_thunk(value))
} else {
None
};
constraints.push(Constraint::ArrayItems(item));
let constraints = constraints
.into_iter()
.map(|constraint| ConstraintEntry {
constraint,
span: Span::default(),
})
.collect();
let constraints = normalize_constraints(constraints, Span::default())?;
Ok(RuntimeValue::Abstract(AbstractValue {
constraints,
default,
}))
}
HostValue::Object(fields) => {
let mut object = ObjectValue { fields: Vec::new() };
for field in fields {
@@ -1147,7 +1248,7 @@ impl<L: SourceLoader> Engine<L> {
diagnostic
} else {
let span = diagnostic.span;
diagnostic.with_label(span, format!("while {phase} `{}`", path.join(".")))
diagnostic.with_label(span, format!("while {phase} `{}`", format_path(path)))
}
}
}
@@ -1170,7 +1271,6 @@ fn primitive_type(name: &str) -> Option<PrimitiveType> {
"Int" => Some(PrimitiveType::Int),
"Float" => Some(PrimitiveType::Float),
"Bool" => Some(PrimitiveType::Bool),
"Array" => Some(PrimitiveType::Array),
_ => None,
}
}
@@ -1251,13 +1351,25 @@ fn value_matches_primitive(value: &RuntimeValue, primitive: PrimitiveType) -> bo
) | (
RuntimeValue::Concrete(ConcreteValue::Bool(_)),
PrimitiveType::Bool
) | (
RuntimeValue::Concrete(ConcreteValue::Array(_)),
PrimitiveType::Array
)
)
}
fn format_path(path: &[String]) -> String {
let mut output = String::new();
for part in path {
if part.starts_with('[') {
output.push_str(part);
} else {
if !output.is_empty() {
output.push('.');
}
output.push_str(part);
}
}
output
}
#[cfg(feature = "regex")]
fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> {
let RuntimeValue::Concrete(ConcreteValue::String(value)) = value else {
@@ -1678,6 +1790,96 @@ mod tests {
);
}
#[test]
fn array_constraint_accepts_matching_items() {
let data = eval_data("[...(Int & >= 1)] & [1, 2, 3]");
assert_eq!(
data,
Data::Array(vec![Data::Int(1), Data::Int(2), Data::Int(3)])
);
}
#[test]
fn array_constraint_accepts_empty_array() {
let data = eval_data("[...Int] & []");
assert_eq!(data, Data::Array(vec![]));
}
#[test]
fn array_constraints_compose_per_item() {
let data = eval_data("[...Int] & [...> 0] & [1, 2, 3]");
assert_eq!(
data,
Data::Array(vec![Data::Int(1), Data::Int(2), Data::Int(3)])
);
}
#[test]
fn nested_array_constraints_validate_recursively() {
let data = eval_data(r#"[...[...String]] & [["api"], [], ["worker"]]"#);
assert_eq!(
data,
Data::Array(vec![
Data::Array(vec![Data::String(String::from("api"))]),
Data::Array(vec![]),
Data::Array(vec![Data::String(String::from("worker"))]),
])
);
}
#[test]
fn array_constraint_rejects_invalid_item_with_index() {
let parsed = parse_source("[...Int] & [1, \"two\", 3]").unwrap();
let mut engine = Engine::from_parse(parsed.ast, parsed.root);
let error = engine.eval_root().unwrap_err();
assert_eq!(error.kind, DiagnosticKind::ConstraintViolation);
assert!(
error
.labels
.iter()
.any(|label| label.message.contains("array element [1]"))
);
}
#[test]
fn array_constraint_applies_object_defaults() {
let data = eval_data(
r#"
[...{
name = String;
enabled = Bool default true;
}] & [{ name = "api"; }]
"#,
);
let Data::Array(items) = data else { panic!() };
let Data::Object(fields) = &items[0] else {
panic!()
};
assert_eq!(fields[0].value, Data::String(String::from("api")));
assert_eq!(fields[1].value, Data::Bool(true));
}
#[test]
fn array_constraint_validates_default_array() {
let data = eval_data("[...String] default [\"api\", \"worker\"]");
assert_eq!(
data,
Data::Array(vec![
Data::String(String::from("api")),
Data::String(String::from("worker")),
])
);
}
#[test]
fn removed_array_primitive_reports_migration() {
let parsed = parse_source("Array").unwrap();
let mut engine = Engine::from_parse(parsed.ast, parsed.root);
let error = engine.eval_root().unwrap_err();
assert_eq!(error.kind, DiagnosticKind::UnresolvedIdentifier);
assert!(error.message.contains("[...String]"));
}
#[test]
fn array_concat_has_lower_precedence_than_arithmetic() {
let data = eval_data("[1 + 1] ++ [2 * 2]");
+22 -1
View File
@@ -32,6 +32,7 @@ pub enum TokenKind {
Semicolon,
Comma,
Dot,
Ellipsis,
Colon,
Equal,
EqualEqual,
@@ -141,7 +142,18 @@ impl<'a> Lexer<'a> {
}
b'.' => {
self.pos += 1;
TokenKind::Dot
if self.consume(b'.') {
if self.consume(b'.') {
TokenKind::Ellipsis
} else {
return Err(Diagnostic::syntax(
self.span(start, self.pos),
"expected third '.' in ellipsis",
));
}
} else {
TokenKind::Dot
}
}
b':' => {
self.pos += 1;
@@ -432,4 +444,13 @@ mod tests {
assert_eq!(tokens[3].kind, TokenKind::Amp);
assert_eq!(tokens[4].kind, TokenKind::Gte);
}
#[test]
fn tokenizes_array_constraint_ellipsis() {
let tokens = Lexer::new("[...String]").tokenize().unwrap();
assert_eq!(tokens[0].kind, TokenKind::LBracket);
assert_eq!(tokens[1].kind, TokenKind::Ellipsis);
assert!(matches!(tokens[2].kind, TokenKind::Ident(_)));
assert_eq!(tokens[3].kind, TokenKind::RBracket);
}
}
+25
View File
@@ -338,6 +338,17 @@ impl Parser {
}
fn parse_array_after_lbracket(&mut self, start_span: Span) -> Result<ExprId> {
if self.consume_kind(&TokenKind::Ellipsis).is_some() {
let item = self.parse_expr(0)?;
let _ = self.consume_kind(&TokenKind::Comma);
self.expect_kind(
&TokenKind::RBracket,
"expected ']' after array element constraint",
)?;
let span = start_span.join(self.previous_span());
return Ok(self.ast.push(Expr::ArrayConstraint { item }, span));
}
let mut items = Vec::new();
if self.consume_kind(&TokenKind::RBracket).is_some() {
return Ok(self
@@ -674,4 +685,18 @@ mod tests {
};
assert_eq!(fields[0].path, ["feature", "enable"]);
}
#[test]
fn parses_array_constraint() {
let parsed = parse_source("[...(String & /^api-/)]").unwrap();
let Expr::ArrayConstraint { item } = parsed.ast.get(parsed.root).expr else {
panic!()
};
assert!(matches!(parsed.ast.get(item).expr, Expr::Binary { .. }));
}
#[test]
fn rejects_array_constraint_without_element_constraint() {
assert!(parse_source("[...]").is_err());
}
}
+1 -1
View File
@@ -68,6 +68,7 @@ pub struct ConstraintEntry {
#[derive(Debug, Clone, PartialEq)]
pub enum Constraint {
Type(PrimitiveType),
ArrayItems(ThunkId),
Compare(CompareOp, LiteralValue),
Regex(String),
BuiltinPredicate(String),
@@ -79,7 +80,6 @@ pub enum PrimitiveType {
Int,
Float,
Bool,
Array,
}
#[derive(Debug, Clone, PartialEq)]
+2 -2
View File
@@ -198,9 +198,9 @@ impl IntoHostValue for bool {
}
}
impl<T> DecodalSchema for Vec<T> {
impl<T: DecodalSchema> DecodalSchema for Vec<T> {
fn decodal_schema() -> HostValue {
HostValue::array_type()
HostValue::array_of(T::decodal_schema())
}
}