Add associative map constraints and range refinement

This commit is contained in:
2026-08-14 06:26:15 +09:00
parent 8198b615a8
commit cc6ab40807
52 changed files with 8951 additions and 6918 deletions
+7
View File
@@ -53,6 +53,9 @@ pub enum Expr {
ArrayConstraint {
item: ExprId,
},
MapConstraint {
value: ExprId,
},
Let {
bindings: Vec<Field>,
body: ExprId,
@@ -94,6 +97,10 @@ pub enum Expr {
base: ExprId,
fallback: ExprId,
},
As {
narrower: ExprId,
wider: ExprId,
},
CompareConstraint {
op: CompareOp,
value: ExprId,
+46
View File
@@ -12,6 +12,7 @@ pub fn normalize_constraints(
) -> crate::Result<Vec<ConstraintEntry>> {
let mut primitive: Option<(PrimitiveType, Span)> = None;
let mut array_span: Option<Span> = None;
let mut map_span: Option<Span> = None;
let mut lower: Option<(Bound, Span)> = None;
let mut upper: Option<(Bound, Span)> = None;
let mut rest = Vec::new();
@@ -69,6 +70,13 @@ pub fn normalize_constraints(
span: entry.span,
});
}
Constraint::MapValues(value) => {
map_span.get_or_insert(entry.span);
rest.push(ConstraintEntry {
constraint: Constraint::MapValues(value),
span: entry.span,
});
}
Constraint::BuiltinPredicate(name) => rest.push(ConstraintEntry {
constraint: Constraint::BuiltinPredicate(name),
span: entry.span,
@@ -86,6 +94,26 @@ pub fn normalize_constraints(
.with_label(array_span, "array element constraint"));
}
if let (Some((_, primitive_span)), Some(map_span)) = (primitive, map_span) {
return Err(Diagnostic::new(
DiagnosticKind::Conflict,
span,
"map value constraints conflict with primitive type constraints",
)
.with_label(primitive_span, "primitive constraint")
.with_label(map_span, "map value constraint"));
}
if let (Some(array_span), Some(map_span)) = (array_span, map_span) {
return Err(Diagnostic::new(
DiagnosticKind::Conflict,
span,
"array and map constraints conflict",
)
.with_label(array_span, "array element constraint")
.with_label(map_span, "map value constraint"));
}
if let Some(array_span) = array_span
&& (lower.is_some() || upper.is_some())
{
@@ -104,6 +132,24 @@ pub fn normalize_constraints(
return Err(diagnostic);
}
if let Some(map_span) = map_span
&& (lower.is_some() || upper.is_some())
{
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
span,
"numeric comparison constraints conflict with map value constraints",
)
.with_label(map_span, "map value 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, _))
+32 -1
View File
@@ -15,6 +15,11 @@ pub enum HostValue {
constraints: Vec<Constraint>,
default: Option<Box<HostValue>>,
},
MapConstraint {
value: Box<HostValue>,
constraints: Vec<Constraint>,
default: Option<Box<HostValue>>,
},
Object(Vec<HostField>),
Abstract {
constraints: Vec<Constraint>,
@@ -104,6 +109,14 @@ impl HostValue {
}
}
pub fn map_of(value: HostValue) -> Self {
Self::MapConstraint {
value: Box::new(value),
constraints: Vec::new(),
default: None,
}
}
pub fn builtin_predicate(name: impl Into<String>) -> Self {
Self::abstract_with_constraint(Constraint::BuiltinPredicate(name.into()))
}
@@ -118,7 +131,9 @@ 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::ArrayConstraint { constraints, .. } | Self::MapConstraint { constraints, .. } => {
constraints.push(constraint)
}
_ => {
self = Self::Abstract {
constraints: alloc::vec![constraint],
@@ -169,6 +184,22 @@ impl HostValue {
Span::default(),
"host value already has a default",
)),
Self::MapConstraint {
value: map_value,
constraints,
default: None,
} => Ok(Self::MapConstraint {
value: map_value,
constraints,
default: Some(Box::new(value)),
}),
Self::MapConstraint {
default: Some(_), ..
} => Err(Diagnostic::new(
DiagnosticKind::DefaultConflict,
Span::default(),
"host value already has a default",
)),
Self::Abstract {
constraints,
default: None,
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,6 +22,7 @@ pub enum TokenKind {
Match,
Import,
Default,
As,
Underscore,
LBrace,
RBrace,
@@ -406,6 +407,7 @@ impl<'a> Lexer<'a> {
"match" => TokenKind::Match,
"import" => TokenKind::Import,
"default" => TokenKind::Default,
"as" => TokenKind::As,
_ => TokenKind::Ident(String::from(text)),
}
}
+38
View File
@@ -245,6 +245,13 @@ impl Parser {
},
span,
),
InfixKind::As => self.ast.push(
Expr::As {
narrower: lhs,
wider: rhs,
},
span,
),
};
}
@@ -319,6 +326,16 @@ impl Parser {
}
fn parse_object_after_lbrace(&mut self, start_span: Span) -> Result<ExprId> {
if self.consume_kind(&TokenKind::Ellipsis).is_some() {
let value = self.parse_expr(0)?;
self.expect_kind(
&TokenKind::RBrace,
"expected '}' after map value constraint",
)?;
let span = start_span.join(self.previous_span());
return Ok(self.ast.push(Expr::MapConstraint { value }, span));
}
let mut fields = Vec::new();
if self.consume_kind(&TokenKind::RBrace).is_some() {
return Ok(self
@@ -544,6 +561,7 @@ impl Parser {
fn peek_infix(&self) -> Option<(InfixKind, u8, u8)> {
match self.peek_kind() {
TokenKind::As => Some((InfixKind::As, 0, 1)),
TokenKind::Default => Some((InfixKind::Default, 1, 2)),
TokenKind::SlashSlash => Some((InfixKind::Patch, 3, 4)),
TokenKind::Amp => Some((InfixKind::And, 5, 6)),
@@ -652,6 +670,7 @@ impl Parser {
#[derive(Debug, Clone, Copy)]
enum InfixKind {
As,
Add,
Sub,
Mul,
@@ -720,4 +739,23 @@ mod tests {
fn rejects_array_constraint_without_element_constraint() {
assert!(parse_source("[...]").is_err());
}
#[test]
fn parses_map_constraint() {
let parsed = parse_source("{...String}").unwrap();
let Expr::MapConstraint { value } = parsed.ast.get(parsed.root).expr else {
panic!()
};
assert!(matches!(parsed.ast.get(value).expr, Expr::Ident(_)));
}
#[test]
fn parses_as_below_composition() {
let parsed = parse_source("value & override as Schema").unwrap();
let Expr::As { narrower, wider } = parsed.ast.get(parsed.root).expr else {
panic!()
};
assert!(matches!(parsed.ast.get(narrower).expr, Expr::Binary { .. }));
assert!(matches!(parsed.ast.get(wider).expr, Expr::Ident(_)));
}
}
+1
View File
@@ -69,6 +69,7 @@ pub struct ConstraintEntry {
pub enum Constraint {
Type(PrimitiveType),
ArrayItems(ThunkId),
MapValues(ThunkId),
Compare(CompareOp, LiteralValue),
Regex(String),
BuiltinPredicate(String),
+94 -1
View File
@@ -1,4 +1,4 @@
use alloc::{format, string::String, vec::Vec};
use alloc::{collections::BTreeMap, format, string::String, vec::Vec};
use crate::{Data, HostValue};
@@ -225,3 +225,96 @@ impl<T: IntoHostValue> IntoHostValue for Vec<T> {
HostValue::array(self.into_iter().map(IntoHostValue::into_host_value))
}
}
impl<T: DecodalSchema> DecodalSchema for BTreeMap<String, T> {
fn decodal_schema() -> HostValue {
HostValue::map_of(T::decodal_schema())
}
}
impl<T: DecodalDecode> DecodalDecode for BTreeMap<String, T> {
fn decodal_decode(data: &Data) -> DecodeResult<Self> {
match data {
Data::Object(fields) => fields
.iter()
.map(|field| {
T::decodal_decode(&field.value)
.map(|value| (field.name.clone(), value))
.map_err(|error| prefix_error(&field.name, error))
})
.collect(),
_ => Err(DecodeError::at_type("", "Object")),
}
}
}
impl<T: IntoHostValue> IntoHostValue for BTreeMap<String, T> {
fn into_host_value(self) -> HostValue {
HostValue::object(
self.into_iter()
.map(|(name, value)| (name, value.into_host_value())),
)
}
}
#[cfg(feature = "std")]
impl<T: DecodalSchema> DecodalSchema for std::collections::HashMap<String, T> {
fn decodal_schema() -> HostValue {
HostValue::map_of(T::decodal_schema())
}
}
#[cfg(feature = "std")]
impl<T: DecodalDecode> DecodalDecode for std::collections::HashMap<String, T> {
fn decodal_decode(data: &Data) -> DecodeResult<Self> {
match data {
Data::Object(fields) => fields
.iter()
.map(|field| {
T::decodal_decode(&field.value)
.map(|value| (field.name.clone(), value))
.map_err(|error| prefix_error(&field.name, error))
})
.collect(),
_ => Err(DecodeError::at_type("", "Object")),
}
}
}
#[cfg(feature = "std")]
impl<T: IntoHostValue> IntoHostValue for std::collections::HashMap<String, T> {
fn into_host_value(self) -> HostValue {
let mut fields: Vec<_> = self
.into_iter()
.map(|(name, value)| (name, value.into_host_value()))
.collect();
fields.sort_by(|left, right| left.0.cmp(&right.0));
HostValue::object(fields)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{Constraint, DataField, PrimitiveType};
#[test]
fn string_maps_expose_map_schemas_and_decode_objects() {
assert!(matches!(
BTreeMap::<String, i64>::decodal_schema(),
HostValue::MapConstraint { value, .. }
if matches!(
*value,
HostValue::Abstract { ref constraints, .. }
if constraints == &[Constraint::Type(PrimitiveType::Int)]
)
));
let data = Data::Object(alloc::vec![DataField {
name: String::from("api"),
value: Data::Int(8080),
}]);
let decoded = BTreeMap::<String, i64>::decodal_decode(&data).unwrap();
assert_eq!(decoded.get("api"), Some(&8080));
}
}
@@ -222,6 +222,7 @@ fn builtin_items() -> Vec<CompletionItem> {
("match", CompletionKind::Keyword, "pattern matching", 5),
("import", CompletionKind::Keyword, "load a module", 5),
("default", CompletionKind::Keyword, "fallback value", 0),
("as", CompletionKind::Keyword, "refine narrower as wider", 0),
("true", CompletionKind::Constant, "Bool", 0),
("false", CompletionKind::Constant, "Bool", 0),
("String", CompletionKind::Type, "string constraint", 5),
@@ -276,6 +277,10 @@ fn fields_from_host_value(value: &HostValue) -> FieldTree {
default: Some(value),
..
}
| HostValue::MapConstraint {
default: Some(value),
..
}
| HostValue::Abstract {
default: Some(value),
..
+31
View File
@@ -169,6 +169,11 @@ impl<'a> Formatter<'a> {
self.write_expr(out, *item, indent, 0);
out.push(']');
}
Expr::MapConstraint { value } => {
out.push_str("{...");
self.write_expr(out, *value, indent, 0);
out.push('}');
}
Expr::Let { bindings, body } => self.write_let(out, node.span, bindings, *body, indent),
Expr::Import(_) => {
out.push_str("import ");
@@ -219,6 +224,11 @@ impl<'a> Formatter<'a> {
out.push_str(" default ");
self.write_expr(out, *fallback, indent, precedence + 1);
}
Expr::As { narrower, wider } => {
self.write_expr(out, *narrower, indent, precedence);
out.push_str(" as ");
self.write_expr(out, *wider, indent, precedence + 1);
}
Expr::CompareConstraint { op, value } => {
out.push_str(compare_operator(*op));
out.push(' ');
@@ -461,6 +471,7 @@ impl<'a> Formatter<'a> {
fn precedence(&self, id: ExprId) -> u8 {
match &self.ast.get(id).expr {
Expr::As { .. } => 0,
Expr::Default { .. } => 1,
Expr::Binary { op, .. } => match op {
BinaryOp::Patch => 3,
@@ -508,7 +519,11 @@ impl<'a> Formatter<'a> {
}
Expr::Array(items) => items.iter().all(|item| self.is_inline_expr(*item)),
Expr::ArrayConstraint { item } => self.is_inline_expr(*item),
Expr::MapConstraint { value } => self.is_inline_expr(*value),
Expr::Parenthesized { expr } => self.is_inline_expr(*expr),
Expr::As { narrower, wider } => {
self.is_inline_expr(*narrower) && self.is_inline_expr(*wider)
}
Expr::Object(_) | Expr::Let { .. } | Expr::Function { .. } | Expr::Match { .. } => {
false
}
@@ -670,6 +685,18 @@ mod tests {
);
}
#[test]
fn formats_map_constraints_and_range_ascription() {
let source =
"config={service={port=8080;};}as{service={port=Int;};};labels={a=1;b=2;}as{...Int};";
let formatted = format_source(source).unwrap();
assert_eq!(
formatted,
"config = {\n service = {\n port = 8080;\n };\n} as {\n service = {\n port = Int;\n };\n};\nlabels = {\n a = 1;\n b = 2;\n} as {...Int};\n"
);
assert_eq!(format_source(&formatted).unwrap(), formatted);
}
#[test]
fn formats_let_functions_and_precedence_from_the_canonical_ast() {
let source = "value=let x=1+2*3;in(a:Int)=>{result=(x+a)*2;};";
@@ -743,6 +770,10 @@ mod tests {
include_str!("../../../examples/import/schema.dcdl"),
),
("logical", include_str!("../../../examples/logical.dcdl")),
(
"map-ascription",
include_str!("../../../examples/map-ascription.dcdl"),
),
(
"regex/main",
include_str!("../../../examples/regex/main.dcdl"),
+27
View File
@@ -84,6 +84,16 @@ fn object(fields: &Map<String, Value>) -> Result<HostValue, String> {
default,
})
}
"Map" => {
let value = fields
.get("value")
.ok_or_else(|| String::from("Map descriptor requires `value`"))?;
Ok(HostValue::MapConstraint {
value: Box::new(from_json(value)?),
constraints,
default,
})
}
"Abstract" => Ok(HostValue::Abstract {
constraints,
default,
@@ -217,6 +227,23 @@ mod tests {
);
}
#[test]
fn converts_map_schema_descriptors() {
let value = serde_json::json!({
"$decodal": "Map",
"value": { "$decodal": "Bool" },
"default": {},
});
assert_eq!(
from_json(&value).unwrap(),
HostValue::MapConstraint {
value: Box::new(HostValue::bool_type()),
constraints: Vec::new(),
default: Some(Box::new(HostValue::object([] as [(&str, HostValue); 0]))),
}
);
}
#[test]
fn rejects_null_values() {
assert!(