fix: slot fixes and unification

This commit is contained in:
Ivan Miletic
2026-09-21 12:44:49 +02:00
parent eef88c7c2c
commit 3c0dfcd42f
32 changed files with 2100 additions and 135 deletions
+2
View File
@@ -285,6 +285,8 @@ export interface PolicySlot {
inString: string | null;
listed: string[];
autoOpen: boolean;
/** Names bound by enclosing closures at the caret, innermost first (`x` for `as x`, `#` otherwise). */
locals: Array<{ name: string; type: PolicyVariableType }>;
}
export type PolicyLiteralFact =
| {
+4 -1
View File
@@ -69,7 +69,6 @@ export type PolicyDiagnosticCode =
| 'UNRESOLVED_FUNCTION_TYPE'
| 'IMPLICIT_ANY'
| 'UNCHECKED_NODE'
| 'NULLABILITY_DIVERGENCE'
| 'REDUNDANT_NULLISH'
| 'REPEATED_DERIVATION'
| 'PREFER_MATCH'
@@ -286,6 +285,8 @@ export interface PolicySlot {
inString: string | null;
listed: string[];
autoOpen: boolean;
/** Names bound by enclosing closures at the caret, innermost first (`x` for `as x`, `#` otherwise). */
locals: Array<{ name: string; type: PolicyVariableType }>;
}
export type PolicyLiteralFact =
| {
@@ -643,6 +644,8 @@ export interface PolicyCompletion {
kind: string
detail: string
info: string
/** Text to append after accepting the item: `.` into an object, a space before an operator. */
follow?: string
}
export interface PolicyConditionalSchema {
+23 -8
View File
@@ -306,6 +306,8 @@ pub struct PolicyCompletion {
pub kind: String,
pub detail: String,
pub info: String,
/// Text to append after accepting the item: `.` into an object, a space before an operator.
pub follow: Option<String>,
}
#[napi(object)]
@@ -416,7 +418,15 @@ fn variable_type_from_json(value: &Value) -> zen_expression::variable::VariableT
}
pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType) -> Value {
use zen_expression::variable::VariableType;
variable_type_to_json_at(vt, &mut Vec::new())
}
/// Objects already on the path (cycles) or nested beyond `MAX_TYPE_DEPTH` are emitted without fields.
fn variable_type_to_json_at(
vt: &zen_expression::variable::VariableType,
path: &mut Vec<*const ()>,
) -> Value {
use zen_expression::variable::{VariableType, MAX_TYPE_DEPTH};
match vt {
VariableType::Any => serde_json::json!({ "type": "any" }),
@@ -437,14 +447,18 @@ pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType)
}
VariableType::Array(inner) => serde_json::json!({
"type": "array",
"items": variable_type_to_json(inner),
"items": variable_type_to_json_at(inner, path),
}),
VariableType::Object(obj) => {
let fields: serde_json::Map<std::string::String, Value> = obj
.borrow()
.iter()
.map(|(k, v)| (k.to_string(), variable_type_to_json(v)))
.collect();
let ptr = std::rc::Rc::as_ptr(obj) as *const ();
let mut fields = serde_json::Map::new();
if path.len() < MAX_TYPE_DEPTH && !path.contains(&ptr) {
path.push(ptr);
for (k, v) in obj.borrow().iter() {
fields.insert(k.to_string(), variable_type_to_json_at(v, path));
}
path.pop();
}
serde_json::json!({
"type": "object",
"fields": fields,
@@ -452,7 +466,7 @@ pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType)
}
VariableType::Nullable(inner) => serde_json::json!({
"type": "nullable",
"inner": variable_type_to_json(inner),
"inner": variable_type_to_json_at(inner, path),
}),
}
}
@@ -876,6 +890,7 @@ impl Workspace {
.unwrap_or_default(),
detail: c.detail,
info: c.info,
follow: c.follow.map(String::from),
})
.collect())
}
+8 -1
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use napi::Env;
use napi_derive::napi;
use serde_json::Value;
use serde_json::{json, Value};
use zen_engine::workspace::{self, CursorScope, ExpressionKind, SlotResponse, SlotRole};
use zen_expression::intellisense::IntelliSense;
use zen_expression::slot::LabelResolver;
@@ -130,6 +130,13 @@ fn slot_response_json(response: SlotResponse) -> napi::Result<Value> {
let slot = object_mut(slot)?;
slot.insert("expected".into(), type_json(&response.slot.expected));
slot.insert("operand".into(), type_json(&response.slot.operand));
let locals = response
.slot
.locals
.iter()
.map(|l| json!({ "name": l.name, "type": variable_type_to_json(&l.kind) }))
.collect();
slot.insert("locals".into(), Value::Array(locals));
Ok(value)
}
+9 -2
View File
@@ -9,7 +9,7 @@ use zen_expression::{Isolate, IsolateError};
use super::property_read::ReadFlattener;
use super::type_check::TypeCheck;
use crate::policy::ir::PropertyPath;
use crate::policy::queries::dependency::PathPrefix;
use crate::policy::queries::dependency::{DataModelPaths, PathPrefix};
use crate::policy::queries::scope::VariableTypeScope;
use crate::workspace::db::AnalysisPass;
use crate::workspace::types::{
@@ -59,6 +59,7 @@ pub struct ExecutionError {
pub type SharedDictionaryTypes = Rc<ahash::HashMap<Arc<str>, VariableType>>;
pub type SharedPoisonedPaths = Rc<RefCell<ahash::HashSet<Arc<str>>>>;
pub type SharedDeclaredPaths = Rc<DataModelPaths>;
pub struct AnalysisContext {
scope: VariableType,
@@ -71,11 +72,13 @@ pub struct AnalysisContext {
intellisense: SharedIntelliSense,
dictionary_types: SharedDictionaryTypes,
poisoned_paths: SharedPoisonedPaths,
declared_paths: SharedDeclaredPaths,
/// Attached to diagnostics that name no target of their own, e.g. parse errors of one table cell.
default_target: Option<CursorTarget>,
}
impl AnalysisContext {
#[allow(clippy::too_many_arguments)]
pub fn new(
scope: VariableType,
policy_path: Arc<str>,
@@ -84,6 +87,7 @@ impl AnalysisContext {
pass: AnalysisPass,
dictionary_types: SharedDictionaryTypes,
poisoned_paths: SharedPoisonedPaths,
declared_paths: SharedDeclaredPaths,
) -> Self {
Self {
scope,
@@ -96,6 +100,7 @@ impl AnalysisContext {
intellisense,
dictionary_types,
poisoned_paths,
declared_paths,
default_target: None,
}
}
@@ -212,7 +217,9 @@ impl AnalysisContext {
}
self.poisoned_paths.borrow_mut().insert(path.clone());
}
if matches!(self.pass, AnalysisPass::Enriched) {
if matches!(self.pass, AnalysisPass::Enriched)
&& self.declared_paths.matches_prefix(&path).is_none()
{
self.scope.insert_at_path(&path, &resolved_type, true);
}
self.writes.push(WriteTarget {
+2 -2
View File
@@ -29,8 +29,8 @@ pub use assertion::{AssertionDoc, AssertionIr};
pub(crate) use context::IntelliSenseSource;
pub use context::{
AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation,
InstanceSource, PropertyRead, SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths,
WriteTarget,
InstanceSource, PropertyRead, SharedDeclaredPaths, SharedDictionaryTypes, SharedIntelliSense,
SharedPoisonedPaths, WriteTarget,
};
pub use decision_table::{DecisionTableDoc, DecisionTableIr, DeclaredType};
pub(crate) use decision_table::{DictionaryCandidate, TableSelection, ROW_ID_KEY};
+2 -1
View File
@@ -221,7 +221,8 @@ impl DataModelIr {
visited: &mut HashSet<Arc<str>>,
) -> VariableType {
let inner = match &prop.kind {
PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String,
PropertyTypeIr::String => VariableType::String,
PropertyTypeIr::Date => VariableType::Date,
PropertyTypeIr::Enum(values) => VariableType::Enum(None, enum_values_to_rc(values)),
PropertyTypeIr::Number => VariableType::Number,
PropertyTypeIr::Boolean => VariableType::Bool,
+136 -6
View File
@@ -1,14 +1,18 @@
use std::cell::RefCell;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::rc::Rc;
use std::sync::Arc;
use ahash::{HashMap, HashMapExt, HashSet, HashSetExt};
use petgraph::algo::{tarjan_scc, toposort};
use petgraph::algo::tarjan_scc;
use petgraph::prelude::{NodeIndex, StableDiGraph};
use petgraph::Direction;
use zen_expression::variable::VariableType;
use crate::policy::blocks::{
AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDictionaryTypes,
SharedIntelliSense, SharedPoisonedPaths, WriteTarget,
AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDeclaredPaths,
SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths, WriteTarget,
};
use crate::policy::ir::{DataModelIr, ParsedPolicy, PropertyPath};
use crate::policy::queries::path::{PathClassifier, PathRoot};
@@ -66,6 +70,30 @@ pub struct EnrichedState {
pub scope: VariableType,
pub per_rule: Vec<RuleEnrichedAnalysis>,
pub diagnostics: Vec<Diagnostic>,
base_fields: HashMap<Rc<str>, VariableType>,
write_log: Vec<(PropertyPath, VariableType)>,
log_start: HashMap<BlockRef, usize>,
block_scopes: RefCell<HashMap<BlockRef, VariableType>>,
}
impl EnrichedState {
/// Scope the block was analyzed with: base scope plus the writes scheduled before it.
pub(crate) fn scope_before(&self, block: &BlockRef) -> VariableType {
let Some(&end) = self.log_start.get(block) else {
return self.scope.shallow_clone();
};
if let Some(cached) = self.block_scopes.borrow().get(block) {
return cached.shallow_clone();
}
let scope = VariableType::Object(Rc::new(RefCell::new(self.base_fields.clone())));
for (path, resolved_type) in &self.write_log[..end] {
scope.insert_at_path(path, resolved_type, true);
}
self.block_scopes
.borrow_mut()
.insert(block.clone(), scope.shallow_clone());
scope
}
}
#[derive(Debug, Clone)]
@@ -86,6 +114,8 @@ pub struct PropertyNode {
pub resolved_type: VariableType,
pub written_by: Option<BlockRef>,
pub instance_source: Option<InstanceSource>,
/// Writer's document position plus one (0 for a free read): the tie-break between independent paths.
pub rank: usize,
}
impl PropertyNode {
@@ -309,6 +339,7 @@ impl EvalGraph {
}
impl Snapshot {
#[allow(clippy::too_many_arguments)]
fn analyze_block(
rule: &Block,
policy_path: &Arc<str>,
@@ -317,6 +348,7 @@ impl Snapshot {
intellisense: &SharedIntelliSense,
dictionary_types: &SharedDictionaryTypes,
poisoned_paths: &SharedPoisonedPaths,
declared_paths: &SharedDeclaredPaths,
) -> AnalysisSummary {
let mut ctx = AnalysisContext::new(
rule_scope,
@@ -326,6 +358,7 @@ impl Snapshot {
pass,
dictionary_types.clone(),
poisoned_paths.clone(),
declared_paths.clone(),
);
rule.kind.analyze(&mut ctx);
ctx.finish()
@@ -356,6 +389,7 @@ impl Snapshot {
let no_dictionaries: SharedDictionaryTypes = Rc::new(ahash::HashMap::default());
let no_poison: SharedPoisonedPaths = Default::default();
let no_declared: SharedDeclaredPaths = Default::default();
let policy_shallow = cache.shallow_or_compute(path, p, || {
p.policy
.rules()
@@ -368,6 +402,7 @@ impl Snapshot {
intellisense,
&no_dictionaries,
&no_poison,
&no_declared,
);
RuleShallowAnalysis {
policy_path: path.clone(),
@@ -418,7 +453,7 @@ impl Snapshot {
let entity_form_map = EntityForm::new(entity_sources);
let entity_form = |path: &str| -> Option<String> { entity_form_map.rewrite(path) };
for &rule in per_rule {
for (rank, &rule) in per_rule.iter().enumerate() {
for read in &rule.reads {
node_map.entry(read.path.clone()).or_insert_with(|| {
graph.add_node(PropertyNode {
@@ -426,6 +461,7 @@ impl Snapshot {
resolved_type: VariableType::Any,
written_by: None,
instance_source: None,
rank: 0,
})
});
}
@@ -441,6 +477,7 @@ impl Snapshot {
resolved_type: write.resolved_type.shallow_clone(),
written_by: None,
instance_source: None,
rank: 0,
})
});
@@ -456,6 +493,7 @@ impl Snapshot {
block_id: rule.block_id.clone(),
});
node.instance_source = write.instance_source.clone();
node.rank = rank + 1;
}
let path = write.path.as_ref();
@@ -473,6 +511,7 @@ impl Snapshot {
resolved_type: VariableType::Any,
written_by: None,
instance_source: None,
rank: 0,
})
});
if !writers.contains_key(&prefix_path) {
@@ -484,6 +523,7 @@ impl Snapshot {
policy_path: rule.policy_path.clone(),
block_id: rule.block_id.clone(),
});
graph[anc_idx].rank = rank + 1;
}
if idx != anc_idx {
graph.add_edge(idx, anc_idx, ());
@@ -537,7 +577,7 @@ impl Snapshot {
}
pub(crate) fn compute_execution_order(graph: &DependencyGraph) -> Vec<PropertyPath> {
if let Ok(order) = toposort(&graph.graph, None) {
if let Some(order) = Self::stable_toposort(&graph.graph) {
return order
.into_iter()
.filter(|idx| graph.graph[*idx].written_by.is_some())
@@ -557,6 +597,75 @@ impl Snapshot {
out
}
// Kahn's algorithm with a stable pick: free reads first, then the policy being scheduled in
// document order, then the earliest ready writer of another policy. `None` on a cycle.
fn stable_toposort(graph: &StableDiGraph<PropertyNode, ()>) -> Option<Vec<NodeIndex>> {
type Ready = BinaryHeap<Reverse<(usize, usize)>>;
let policy_of = |idx: NodeIndex| {
graph[idx]
.written_by
.as_ref()
.map(|b| b.policy_path.clone())
};
let mut pending: HashMap<NodeIndex, usize> = HashMap::new();
let mut ready: HashMap<Option<Arc<str>>, Ready> = HashMap::new();
let enqueue = |ready: &mut HashMap<Option<Arc<str>>, Ready>, idx: NodeIndex| {
ready
.entry(policy_of(idx))
.or_default()
.push(Reverse((graph[idx].rank, idx.index())));
};
for idx in graph.node_indices() {
let incoming = graph.edges_directed(idx, Direction::Incoming).count();
if incoming == 0 {
enqueue(&mut ready, idx);
} else {
pending.insert(idx, incoming);
}
}
let has_ready = |ready: &HashMap<Option<Arc<str>>, Ready>, key: &Option<Arc<str>>| {
ready.get(key).is_some_and(|heap| !heap.is_empty())
};
let mut current: Option<Arc<str>> = None;
let mut order = Vec::with_capacity(graph.node_count());
loop {
let key = if has_ready(&ready, &None) {
None
} else if has_ready(&ready, &current) {
current.clone()
} else {
let Some((key, _)) = ready
.iter()
.filter_map(|(key, heap)| heap.peek().map(|head| (key, head.0)))
.min_by_key(|(_, head)| *head)
else {
break;
};
key.clone()
};
let Some(Reverse((_, index))) = ready.get_mut(&key).and_then(|heap| heap.pop()) else {
break;
};
if key.is_some() {
current = key;
}
let idx = NodeIndex::new(index);
order.push(idx);
for next in graph.neighbors_directed(idx, Direction::Outgoing) {
let Some(count) = pending.get_mut(&next) else {
continue;
};
*count -= 1;
if *count == 0 {
pending.remove(&next);
enqueue(&mut ready, next);
}
}
}
(order.len() == graph.node_count()).then_some(order)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn compute_enriched(
base_scope: &VariableType,
graph: &DependencyGraph,
@@ -566,8 +675,15 @@ impl Snapshot {
members: &HashSet<Arc<str>>,
intellisense: &SharedIntelliSense,
dictionary_types: SharedDictionaryTypes,
declared_paths: SharedDeclaredPaths,
) -> EnrichedState {
let scope = base_scope.shallow_clone();
let base_fields = match base_scope {
VariableType::Object(obj) => obj.borrow().clone(),
_ => HashMap::new(),
};
let mut write_log: Vec<(PropertyPath, VariableType)> = Vec::new();
let mut log_start: HashMap<BlockRef, usize> = HashMap::new();
let mut per_rule: Vec<RuleEnrichedAnalysis> = Vec::new();
let mut diagnostics: Vec<Diagnostic> = Vec::new();
@@ -614,6 +730,7 @@ impl Snapshot {
continue;
};
let policy_path = &key.policy_path;
log_start.insert(key.clone(), write_log.len());
let summary = Self::analyze_block(
rule,
policy_path,
@@ -622,10 +739,19 @@ impl Snapshot {
intellisense,
&dictionary_types,
&poisoned_paths,
&declared_paths,
);
for tw in &summary.writes {
if declared_paths.matches_prefix(&tw.path).is_none() {
write_log.push((tw.path.clone(), tw.resolved_type.shallow_clone()));
}
}
if splice {
for tw in &summary.writes {
if declared_paths.matches_prefix(&tw.path).is_some() {
continue;
}
if !scope.insert_at_path(&tw.path, &tw.resolved_type, true) {
diagnostics.push(Diagnostic::error(
DiagnosticCode::InvalidWritePath,
@@ -650,6 +776,10 @@ impl Snapshot {
scope,
per_rule,
diagnostics,
base_fields,
write_log,
log_start,
block_scopes: RefCell::new(HashMap::new()),
}
}
}
@@ -665,7 +795,7 @@ impl PathPrefix {
}
}
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct DataModelPaths {
all: HashSet<PropertyPath>,
optional: HashSet<PropertyPath>,
+1
View File
@@ -538,6 +538,7 @@ impl Db {
&unit.members,
&self.intellisense,
Rc::new(unit.dictionary_types()),
Rc::new(unit.data_model_paths.clone()),
))
})
.clone()
+83 -16
View File
@@ -4,11 +4,13 @@ use ahash::{HashMap, HashMapExt};
use serde_json::Value;
use zen_expression::intellisense::Reference;
use zen_expression::nl::NlResult;
use zen_expression::slot::{field_fits, SlotRole, SlotState};
use zen_expression::variable::VariableType;
use crate::policy::blocks::IntelliSenseSource;
use crate::policy::ir::{DataModelIr, PropertyTypeIr};
use crate::policy::queries::scope::EntityGraph;
use crate::policy::raw::BlockDoc;
use crate::workspace::db::{Db, Snapshot};
use crate::workspace::types::{
BlockRef, Completion, Cursor, CursorTarget, EngineEdit, ExpressionKind, InspectResult,
@@ -33,10 +35,58 @@ impl Db {
let Some((source, _, scope)) = self.resolve_cursor(cursor) else {
return Vec::new();
};
let pos = cursor.pos.min(SpanOps::char_len(&source));
self.cursor_intellisense(cursor)
// The stored source is trimmed: a caret past its end sits in trailing whitespace.
let len = SpanOps::char_len(&source);
let padded: String;
let source: &str = if cursor.pos > len {
padded = format!("{source}{}", " ".repeat((cursor.pos - len) as usize));
&padded
} else {
&source
};
let mut completions = self
.cursor_intellisense(cursor)
.borrow_mut()
.completions(&source, pos, &scope)
.completions(source, cursor.pos, &scope);
let Some(response) = self.slot(cursor, source) else {
return completions;
};
let slot = response.slot;
// Only an operator can follow a finished operand: no field or function belongs there.
if matches!(slot.state, SlotState::Operator | SlotState::Logical) {
return Vec::new();
}
// A value slot of a known scalar type: fields that can never produce it are noise.
if let Some(wanted) = slot.wanted_scalar().map(VariableType::shallow_clone) {
completions.retain(|c| c.var_type.as_ref().is_none_or(|t| field_fits(t, &wanted)));
}
let chains = matches!(response.role, SlotRole::Condition | SlotRole::Unary)
&& self.operator_follows(cursor, source);
for c in &mut completions {
let Some(t) = c.var_type.as_ref() else {
continue;
};
c.follow = match t.unwrap_nullable().0 {
VariableType::Object(_) => Some("."),
_ if chains => Some(" "),
_ => None,
};
}
completions
}
// Whether a leaf field completed at the caret would be followed by an operator: classified on
// the prefix plus a stand-in field and a space, so a call argument, a list or a path does not
// chain.
fn operator_follows(&self, cursor: &Cursor, source: &str) -> bool {
let prefix: String = char::decode_utf16(source.encode_utf16().take(cursor.pos as usize))
.filter_map(Result::ok)
.collect();
let probe = format!("{prefix}x ");
let mut at = cursor.clone();
at.pos = cursor.pos + 2;
self.slot(&at, &probe)
.is_some_and(|r| matches!(r.slot.state, SlotState::Operator | SlotState::Logical))
}
pub(crate) fn cursor_intellisense(
@@ -111,11 +161,12 @@ impl Db {
.flatten();
return Some((kind, scope, expected));
}
let block = self.block_ir(&BlockRef {
let block_ref = BlockRef {
policy_path: cursor.policy_path.clone(),
block_id: cursor.block_id.clone(),
})?;
let scope = self.enriched(&cursor.policy_path).scope.shallow_clone();
};
let block = self.block_ir(&block_ref)?;
let scope = self.enriched(&cursor.policy_path).scope_before(&block_ref);
let dictionaries = self.unit(&cursor.policy_path).dictionary_types();
let intellisense = self.intellisense();
let mut is = intellisense.borrow_mut();
@@ -264,14 +315,32 @@ impl Db {
if self.is_graph(&cursor.policy_path) {
return self.graph_resolve_cursor(cursor);
}
let rule = self.block_ir(&BlockRef {
let block_ref = BlockRef {
policy_path: cursor.policy_path.clone(),
block_id: cursor.block_id.clone(),
})?;
rule.resolve_cursor(
cursor,
self.enriched(&cursor.policy_path).scope.shallow_clone(),
)
};
let rule = self.block_ir(&block_ref)?;
let scope = self.enriched(&cursor.policy_path).scope_before(&block_ref);
rule.resolve_cursor(cursor, scope.shallow_clone())
.or_else(|| {
let source = self.raw_assertion_condition(cursor)?;
Some((source, ExpressionKind::Standard, scope))
})
}
fn raw_assertion_condition(&self, cursor: &Cursor) -> Option<Arc<str>> {
let CursorTarget::Expression { id } = &cursor.target else {
return None;
};
let policy = self.raw_policy(&cursor.policy_path)?;
policy.blocks.iter().find_map(|block| match block {
BlockDoc::Assertion { id: block_id, data } if *block_id == cursor.block_id => data
.conditions
.iter()
.find(|c| c.id == *id)
.map(|c| c.expression.clone()),
_ => None,
})
}
}
@@ -475,10 +544,8 @@ impl EntityGraph {
name: first.clone(),
},
);
match self.next_entity_for_global(&first) {
Some(target) => Some((target, 1)),
None => None,
}
self.next_entity_for_global(&first)
.map(|target| (target, 1))
} else {
None
}
+13 -3
View File
@@ -42,6 +42,13 @@ impl Db {
match &node.kind {
DecisionNodeKind::ExpressionNode { content } => {
if matches!(cursor.target, CursorTarget::ExpressionKey) {
let scope = GraphAnalyzer::scope_with_nodes(
&node_analysis.input,
&node_analysis.nodes_scope,
);
return Some((Arc::from(""), ExpressionKind::Standard, scope));
}
let CursorTarget::Expression { id } = &cursor.target else {
return None;
};
@@ -89,9 +96,12 @@ impl Db {
);
match &cursor.target {
CursorTarget::DecisionTableHead { col } => {
let column = content.inputs.iter().find(|c| c.id == *col)?;
let field = column.field.as_ref()?;
Some((field.clone(), ExpressionKind::Standard, base_scope))
let field = if let Some(column) = content.inputs.iter().find(|c| c.id == *col) {
column.field.clone().unwrap_or_else(|| Arc::from(""))
} else {
content.outputs.iter().find(|c| c.id == *col)?.field.clone()
};
Some((field, ExpressionKind::Standard, base_scope))
}
CursorTarget::DecisionTableCell { row, col } => {
let rule = content
+42 -7
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use zen_expression::variable::VariableType;
use zen_types::decision::{DecisionNode, DecisionNodeKind, DecisionTableContent};
use super::{known_type, CursorScope};
use super::{known_type, literal_union, CursorScope};
use crate::model::GraphContent;
use crate::policy::blocks::IntelliSenseSource;
use crate::policy::queries::scope::VariableTypeScope;
@@ -31,6 +31,12 @@ pub(super) fn graph_scope(db: &Db, cursor: &Cursor) -> Option<CursorScope> {
match &node.kind {
DecisionNodeKind::ExpressionNode { content: rows } => {
if matches!(cursor.target, CursorTarget::ExpressionKey) {
return Some(CursorScope::path(GraphAnalyzer::scope_with_nodes(
&node_analysis.input,
&node_analysis.nodes_scope,
)));
}
let CursorTarget::Expression { id } = &cursor.target else {
return None;
};
@@ -84,7 +90,7 @@ fn table_scope(
|| table.outputs.iter().any(|c| c.id == *col);
known.then(|| CursorScope::path(scope))
}
CursorTarget::DecisionTableCell { col, .. } => {
CursorTarget::DecisionTableCell { row, col } => {
if let Some(column) = table.inputs.iter().find(|c| c.id == *col) {
return Some(match column.field.as_ref().filter(|f| !f.is_empty()) {
Some(field) => {
@@ -94,16 +100,45 @@ fn table_scope(
None => CursorScope::condition(scope),
});
}
table.outputs.iter().any(|c| c.id == *col).then(|| {
let dictionaries = db.graph_dictionary_types(&content.imports);
let expected = GraphAnalyzer::output_expected(table, col, &dictionaries);
CursorScope::value(scope, expected)
})
let column = table.outputs.iter().find(|c| c.id == *col)?;
let dictionaries = db.graph_dictionary_types(&content.imports);
let expected = GraphAnalyzer::output_expected(table, col, &dictionaries)
.or_else(|| {
(!column.field.is_empty())
.then(|| output_schema_type(db, content, &column.field))
.flatten()
})
.or_else(|| sibling_union(db, table, row, col, &scope));
Some(CursorScope::value(scope, expected))
}
_ => None,
}
}
fn sibling_union(
db: &Db,
table: &DecisionTableContent,
row: &Arc<str>,
col: &Arc<str>,
scope: &VariableType,
) -> Option<VariableType> {
let mut merged: Option<VariableType> = None;
for (idx, rule) in table.rules.iter().enumerate() {
if GraphAnalyzer::row_key(rule, idx) == *row {
continue;
}
let Some(cell) = rule.get(col).filter(|c| !c.is_empty()) else {
continue;
};
let cell_type = return_type(db, cell, scope);
merged = Some(match merged {
Some(acc) => acc.merge(&cell_type),
None => cell_type,
});
}
literal_union(merged?)
}
fn output_schema_type(db: &Db, content: &GraphContent, key: &str) -> Option<VariableType> {
let schema = content.nodes.iter().find_map(|node| match &node.kind {
DecisionNodeKind::OutputNode { content } => content.schema.as_ref(),
+37 -7
View File
@@ -4,21 +4,25 @@ use zen_expression::variable::VariableType;
use super::{known_type, literal_union, CursorScope};
use crate::policy::blocks::{
BlockKind, DecisionTableIr, ExpressionIr, IntelliSenseSource, MatchIr,
BlockKind, DecisionTableIr, ExpressionIr, IntelliSenseSource, MatchIr, ROW_ID_KEY,
};
use crate::policy::queries::scope::VariableTypeScope;
use crate::workspace::db::{Db, Unit};
use crate::workspace::types::{BlockRef, Cursor, CursorTarget, ExpressionKind};
pub(super) fn policy_scope(db: &Db, cursor: &Cursor) -> Option<CursorScope> {
let block = db.block_ir(&BlockRef {
let block_ref = BlockRef {
policy_path: cursor.policy_path.clone(),
block_id: cursor.block_id.clone(),
})?;
};
let block = db.block_ir(&block_ref)?;
let unit = db.unit(&cursor.policy_path);
let scope = db.enriched_of_unit(&unit).scope.shallow_clone();
let enriched = db.enriched_of_unit(&unit);
let scope = enriched.scope_before(&block_ref);
match &block.kind {
BlockKind::DecisionTable(table) => table_scope(db, &unit, table, cursor, scope),
BlockKind::DecisionTable(table) => {
table_scope(db, &unit, table, cursor, scope, &enriched.scope)
}
BlockKind::Expression(expression) => expression_scope(db, expression, cursor, scope),
BlockKind::Assertion(_) => assertion_scope(cursor, scope),
BlockKind::Match(block) => match_scope(db, block, cursor, scope),
@@ -31,6 +35,7 @@ fn table_scope(
table: &DecisionTableIr,
cursor: &Cursor,
scope: VariableType,
written: &VariableType,
) -> Option<CursorScope> {
match &cursor.target {
CursorTarget::DecisionTableHead { col } => {
@@ -38,7 +43,7 @@ fn table_scope(
|| table.outputs.iter().any(|c| c.id == *col);
known.then(|| CursorScope::path(scope))
}
CursorTarget::DecisionTableCell { col, .. } => {
CursorTarget::DecisionTableCell { row, col } => {
if let Some(column) = table.inputs.iter().find(|c| c.id == *col) {
return Some(match column.field.as_ref().filter(|f| !f.is_empty()) {
Some(field) => {
@@ -53,7 +58,8 @@ fn table_scope(
.declared
.as_ref()
.and_then(|declared| declared.resolve(&unit.dictionary_types()))
.or_else(|| written_type(db, &scope, column.field.as_ref()));
.or_else(|| written_type(db, written, column.field.as_ref()))
.or_else(|| table_sibling_union(db, table, row, col, &scope));
Some(CursorScope::value(scope, expected))
}
_ => None,
@@ -102,6 +108,30 @@ fn match_scope(
}
}
fn table_sibling_union(
db: &Db,
table: &DecisionTableIr,
row: &Arc<str>,
col: &Arc<str>,
scope: &VariableType,
) -> Option<VariableType> {
let mut merged: Option<VariableType> = None;
for rule in &table.rules {
if rule.get(ROW_ID_KEY) == Some(row) {
continue;
}
let Some(cell) = rule.get(col).filter(|c| !c.is_empty()) else {
continue;
};
let cell_type = return_type(db, cell, scope);
merged = Some(match merged {
Some(acc) => acc.merge(&cell_type),
None => cell_type,
});
}
literal_union(merged?)
}
fn sibling_union(
db: &Db,
block: &MatchIr,
+80
View File
@@ -384,6 +384,52 @@ fn data_model_document() -> serde_json::Value {
})
}
#[test]
fn entities_report_date_properties_as_date() {
let mut ws = PolicyWorkspace::new();
let doc = json!({
"blocks": [{
"id": "dm",
"type": "dataModel",
"props": { "data": {
"name": "claim",
"properties": [
{ "id": "p1", "name": "filedAt", "type": "date", "array": false, "optional": false },
{ "id": "p2", "name": "note", "type": "string", "array": false, "optional": false }
]
} },
"children": []
}]
});
ws.set_policy("p", serde_json::from_value(doc).unwrap());
let entities = ws.entities(&ScopeRequest::for_policy("p"));
let claim = entities
.iter()
.find(|e| e.name.as_ref() == "claim")
.unwrap();
let field = |name: &str| {
claim
.fields
.iter()
.find(|f| f.name.as_ref() == name)
.unwrap()
.resolved_type
.clone()
};
assert_eq!(field("filedAt"), VariableType::Date);
assert_eq!(field("note"), VariableType::String);
let inputs = ws.inputs(&ScopeRequest::for_policy("p"));
let filed = inputs
.iter()
.find(|p| p.path.as_ref() == "claim.filedAt")
.unwrap();
assert_eq!(filed.resolved_type, VariableType::Date);
let skeleton = ws.input_skeleton(&ScopeRequest::for_policy("p"));
assert_eq!(skeleton["claim"]["filedAt"], json!(""));
}
#[test]
fn basic_entities_and_inputs() {
let mut ws = PolicyWorkspace::new();
@@ -4916,3 +4962,37 @@ fn completions_offered_for_empty_and_trailing_space_sources() {
"cursor past trimmed source should offer scope completions: {partial:?}"
);
}
#[test]
fn completions_after_logical_operator_with_trailing_space() {
let doc = json!({
"blocks": [
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
"name": "customer",
"properties": [
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
]
}) }},
{ "id": "partial", "type": "expression", "props": { "data": json!({ "key": "fee", "value": "customer.age > 1 or " }) }}
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy("p", serde_json::from_value(doc).unwrap());
let labels: Vec<String> = ws
.completions(&Cursor {
policy_path: Arc::from("p"),
block_id: Arc::from("partial"),
pos: 20,
target: CursorTarget::Expression { id: Arc::from("x") },
})
.into_iter()
.map(|c| c.label)
.collect();
for expected in ["customer", "$root", "len"] {
assert!(
labels.iter().any(|l| l == expected),
"caret after `or ` should offer scope completions: {labels:?}"
);
}
}
+832 -12
View File
@@ -1,9 +1,10 @@
use serde_json::{json, Value};
use zen_engine::model::DecisionContent;
use zen_engine::policy::{
Cursor, CursorScope, CursorTarget, ExpressionKind, PolicyWorkspace, SlotResponse, SlotRole,
Workspace,
Cursor, CursorScope, CursorTarget, DiagnosticCode, ExpressionKind, PolicyWorkspace,
SlotResponse, SlotRole, Workspace,
};
use zen_expression::intellisense::completion::CompletionKind;
use zen_expression::slot::{LiteralFact, SlotState, ValueOption};
use zen_expression::variable::VariableType;
@@ -53,11 +54,14 @@ fn policy_workspace() -> PolicyWorkspace {
{ "id": "out_number", "name": "Score", "field": "customer.score", "type": "number" },
{ "id": "out_declared", "name": "Tier", "field": "customer.tier" },
{ "id": "out_union", "name": "Band", "field": "customer.band" },
{ "id": "out_untyped", "name": "Label", "field": "customer.label" }
{ "id": "out_untyped", "name": "Label", "field": "customer.label" },
{ "id": "out_nofield", "name": "Unnamed", "field": "" },
{ "id": "out_expr", "name": "Computed", "field": "customer.age + 1" }
],
"rules": [
{ "_id": "r1", "in_declared": "> 18", "in_dict": "\"open\"", "in_stage": "\"open\"", "in_computed": "> 19", "in_condition": "customer.age > 1", "out_dict": "\"open\"", "out_number": "1", "out_declared": "\"gold\"", "out_union": "\"a\"", "out_untyped": "customer.name" },
{ "_id": "r2", "in_declared": "", "in_dict": "", "in_stage": "", "in_computed": "", "in_condition": "", "out_dict": "\"closed\"", "out_number": "2", "out_declared": "\"silver\"", "out_union": "\"b\"", "out_untyped": "customer.name" }
{ "_id": "r1", "in_declared": "> 18", "in_dict": "\"open\"", "in_stage": "\"open\"", "in_computed": "> 19", "in_condition": "customer.age > 1", "out_dict": "\"open\"", "out_number": "1", "out_declared": "\"gold\"", "out_union": "\"a\"", "out_untyped": "customer.name", "out_nofield": "\"x\"", "out_expr": "10" },
{ "_id": "r2", "in_declared": "", "in_dict": "", "in_stage": "", "in_computed": "", "in_condition": "", "out_dict": "\"closed\"", "out_number": "2", "out_declared": "\"silver\"", "out_union": "\"b\"", "out_untyped": "customer.name", "out_nofield": "\"y\"", "out_expr": "20" },
{ "_id": "r3", "in_declared": "", "in_dict": "", "in_stage": "", "in_computed": "", "in_condition": "", "out_dict": "", "out_number": "", "out_declared": "", "out_union": "", "out_untyped": "", "out_nofield": "", "out_expr": "" }
]
} },
"children": []
@@ -260,6 +264,21 @@ fn table_output_cell_without_literal_type_has_no_expectation() {
assert!(scope.expected.is_none());
}
#[test]
fn table_output_cell_without_path_uses_sibling_literal_union() {
let ws = policy_workspace();
let scope = scope_of(&ws, &cursor("dt", cell("r3", "out_nofield")));
assert_eq!(scope.role, SlotRole::Value);
let expected = scope.expected.expect("union of sibling literals");
assert_eq!(enum_values(&expected), vec!["x", "y"]);
let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_nofield")));
assert_eq!(scope.expected, Some(VariableType::Const("y".into())));
let scope = scope_of(&ws, &cursor("dt", cell("r3", "out_expr")));
assert_eq!(scope.expected, Some(VariableType::Number));
}
#[test]
fn table_cell_scope_does_not_require_the_row_to_exist() {
let ws = policy_workspace();
@@ -454,8 +473,12 @@ fn graph_workspace() -> Workspace {
});
let output_schema = json!({
"type": "object",
"properties": { "total": { "type": "number" }, "vip": { "type": "boolean" } },
"required": ["total", "vip"]
"properties": {
"total": { "type": "number" },
"vip": { "type": "boolean" },
"level": { "$dictionary": "customerTier" }
},
"required": ["total", "vip", "level"]
});
ws.set_document(
"g",
@@ -480,11 +503,27 @@ fn graph_workspace() -> Workspace {
],
"outputs": [
{ "id": "o_dict", "name": "Tier", "field": "tier", "type": "customerTier" },
{ "id": "o_plain", "name": "Score", "field": "score" }
{ "id": "o_plain", "name": "Score", "field": "score" },
{ "id": "o_schema", "name": "Total", "field": "total" },
{ "id": "o_schema_dict", "name": "Level", "field": "level" }
],
"rules": [
{ "_id": "r1", "c_field": "> 18", "c_computed": "> 19", "c_condition": "age > 1", "o_dict": "'VIP'", "o_plain": "1" },
{ "_id": "r2", "c_field": "", "c_computed": "", "c_condition": "", "o_dict": "'STD'", "o_plain": "2" }
{ "_id": "r1", "c_field": "> 18", "c_computed": "> 19", "c_condition": "age > 1", "o_dict": "'VIP'", "o_plain": "1", "o_schema": "", "o_schema_dict": "" },
{ "_id": "r2", "c_field": "", "c_computed": "", "c_condition": "", "o_dict": "'STD'", "o_plain": "2", "o_schema": "", "o_schema_dict": "" }
]
})),
node("dt_untyped", "decisionTableNode", json!({
"hitPolicy": "first",
"inputs": [{ "id": "u_age", "name": "Age", "field": "age" }],
"outputs": [
{ "id": "o_bool", "name": "Triggered", "field": "triggered" },
{ "id": "o_str", "name": "Bucket", "field": "bucket" },
{ "id": "o_mixed", "name": "Mixed", "field": "mixed" }
],
"rules": [
{ "_id": "u1", "u_age": "> 18", "o_bool": "false", "o_str": "'a'", "o_mixed": "true" },
{ "_id": "u2", "u_age": "> 60", "o_bool": "true", "o_str": "'b'", "o_mixed": "'x'" },
{ "_id": "u3", "u_age": "", "o_bool": "", "o_str": "", "o_mixed": "" }
]
})),
node("out", "outputNode", json!({ "schema": output_schema.to_string() }))
@@ -493,7 +532,9 @@ fn graph_workspace() -> Workspace {
edge("e1", "in", "calc"),
edge("e2", "calc", "sw"),
edge("e3", "sw", "dt"),
edge("e4", "dt", "out")
edge("e4", "dt", "out"),
edge("e5", "sw", "dt_untyped"),
edge("e6", "dt_untyped", "out")
]
})),
);
@@ -560,9 +601,72 @@ fn graph_table_cells_resolve_like_policy_tables() {
let scope = scope_of(&ws, &graph_cursor("dt", cell("r2", "o_plain")));
assert_eq!(scope.role, SlotRole::Value);
assert_eq!(scope.expected, Some(VariableType::Number));
}
#[test]
fn graph_table_undeclared_output_cell_uses_sibling_literal_union() {
let ws = graph_workspace();
let scope = scope_of(&ws, &graph_cursor("dt_untyped", cell("u3", "o_bool")));
assert_eq!(scope.role, SlotRole::Value);
assert_eq!(scope.expected, Some(VariableType::Bool));
let scope = scope_of(&ws, &graph_cursor("dt_untyped", cell("u3", "o_str")));
assert_eq!(
enum_values(&scope.expected.expect("union of sibling string literals")),
vec!["a", "b"]
);
let scope = scope_of(&ws, &graph_cursor("dt_untyped", cell("u1", "o_str")));
assert_eq!(scope.expected, Some(VariableType::Const("b".into())));
let scope = scope_of(&ws, &graph_cursor("dt_untyped", cell("u3", "o_mixed")));
assert_eq!(scope.role, SlotRole::Value);
assert!(scope.expected.is_none());
}
#[test]
fn graph_table_undeclared_output_column_uses_output_schema_type() {
let ws = graph_workspace();
let scope = scope_of(&ws, &graph_cursor("dt", cell("r1", "o_schema")));
assert_eq!(scope.role, SlotRole::Value);
assert_eq!(scope.expected, Some(VariableType::Number));
let scope = scope_of(&ws, &graph_cursor("dt", cell("r1", "o_schema_dict")));
let expected = scope.expected.expect("dictionary from output schema");
assert!(
matches!(&expected, VariableType::Enum(Some(name), _) if name.as_ref() == "customerTier")
);
assert_eq!(enum_values(&expected), vec!["STD", "VIP"]);
let response = slot_at_graph(&ws, "dt", cell("r1", "o_schema_dict"), "");
assert_eq!(response.slot.state, SlotState::Value);
assert_eq!(
labels(&response.slot.options),
vec!["Very important", "Standard"]
);
}
#[test]
fn graph_expression_key_is_a_path_over_node_input() {
let ws = graph_workspace();
let scope = scope_of(&ws, &graph_cursor("calc", CursorTarget::ExpressionKey));
assert_path(&scope);
assert_eq!(scope.scope.get("age"), VariableType::Number);
let response = slot_at_graph(&ws, "calc", CursorTarget::ExpressionKey, "");
assert_eq!(response.role, SlotRole::Path);
assert_eq!(response.slot.state, SlotState::Path);
assert!(response.slot.auto_open);
let labels: Vec<String> = ws
.completions(&graph_cursor("calc", CursorTarget::ExpressionKey))
.into_iter()
.map(|c| c.label.to_string())
.collect();
assert!(labels.iter().any(|l| l == "age"), "{labels:?}");
}
#[test]
fn graph_table_head_and_transform_input_are_paths() {
let ws = graph_workspace();
@@ -585,6 +689,12 @@ fn slot_at(ws: &Workspace, block: &str, target: CursorTarget, text: &str) -> Slo
ws.slot(&cursor, text).expect("slot resolves")
}
fn slot_at_graph(ws: &Workspace, node: &str, target: CursorTarget, text: &str) -> SlotResponse {
let mut cursor = graph_cursor(node, target);
cursor.pos = text.encode_utf16().count() as u32;
ws.slot(&cursor, text).expect("slot resolves")
}
fn labels(options: &[ValueOption]) -> Vec<&str> {
options.iter().map(|o| o.label.as_str()).collect()
}
@@ -650,6 +760,27 @@ fn slot_graph_switch_statement_is_a_condition() {
assert_eq!(response.slot.replace_span, (6, 6));
}
#[test]
fn slot_graph_undeclared_output_cell_offers_sibling_literals() {
let ws = graph_workspace();
let mut cursor = graph_cursor("dt_untyped", cell("u3", "o_str"));
cursor.pos = 0;
let response = ws.slot(&cursor, "").expect("graph slot");
assert_eq!(response.kind, ExpressionKind::Standard);
assert_eq!(response.role, SlotRole::Value);
assert_eq!(response.slot.state, SlotState::Value);
assert_eq!(labels(&response.slot.options), vec!["a", "b"]);
assert_eq!(
enum_values(&response.expected_type.expect("sibling union")),
vec!["a", "b"]
);
let cursor = graph_cursor("dt_untyped", cell("u3", "o_bool"));
let response = ws.slot(&cursor, "").expect("graph slot");
assert_eq!(response.expected_type, Some(VariableType::Bool));
assert_eq!(response.slot.expected, Some(VariableType::Bool));
}
#[test]
fn slot_spans_are_utf16_code_units() {
let ws = policy_workspace();
@@ -688,7 +819,7 @@ fn slot_is_none_outside_expression_targets() {
fn facts_match_per_target_slot_literals_and_carry_labels() {
let ws = policy_workspace();
let facts = ws.facts("policy");
assert_eq!(facts.len(), 41);
assert_eq!(facts.len(), 57);
for entry in &facts {
let response = ws
.slot(
@@ -837,3 +968,692 @@ fn facts_cover_every_assertion_condition_with_exact_source() {
assert_eq!(second.source.as_ref(), "grounding.hours > 4 ");
assert!(second.literals.is_empty());
}
fn cyclic_entity_workspace() -> PolicyWorkspace {
let doc = json!({
"blocks": [
{
"id": "dm_item",
"type": "dataModel",
"props": { "data": {
"name": "lineItem",
"properties": [
{ "id": "l1", "name": "qty", "type": "number", "array": false, "optional": false },
{ "id": "l2", "name": "claim", "type": "relationship", "target": "claim", "array": false, "optional": false }
]
} },
"children": []
},
{
"id": "dm_addr",
"type": "dataModel",
"props": { "data": {
"name": "address",
"properties": [
{ "id": "a1", "name": "city", "type": "string", "array": false, "optional": false },
{ "id": "a2", "name": "claim", "type": "relationship", "target": "claim", "array": false, "optional": false }
]
} },
"children": []
},
{
"id": "dm",
"type": "dataModel",
"props": { "data": {
"name": "claim",
"properties": [
{ "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false },
{ "id": "p2", "name": "address", "type": "relationship", "target": "address", "array": false, "optional": false },
{ "id": "p3", "name": "items", "type": "relationship", "target": "lineItem", "array": true, "optional": false }
]
} },
"children": []
},
{
"id": "dt",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [
{ "id": "c_items", "name": "Items", "field": "claim.items" },
{ "id": "c_addr", "name": "Address", "field": "claim.address" },
{ "id": "c_cond", "name": "Condition" }
],
"outputs": [{ "id": "o", "name": "Out", "field": "claim.o" }],
"rules": [{ "_id": "r1", "c_items": "", "c_addr": "", "c_cond": "", "o": "" }]
} },
"children": []
},
{
"id": "ex",
"type": "expression",
"props": { "data": { "key": "claim.copy", "value": "claim.amount" } }
}
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy(
"policy",
serde_json::from_value(doc).expect("valid policy fixture"),
);
ws
}
fn assert_finite_json<T: serde::Serialize>(value: &T) -> String {
let json = serde_json::to_string(value).expect("serialisable response");
assert!(
json.len() < 64 * 1024,
"type graph was not cut: {} bytes",
json.len()
);
json
}
#[test]
fn slot_survives_mutually_referencing_entities() {
let ws = cyclic_entity_workspace();
let mut cursor = cursor("dt", cell("r1", "c_items"));
let response = ws.slot(&cursor, "").expect("unary slot over entity array");
assert_eq!(response.role, SlotRole::Unary);
assert!(matches!(
response.subject_type,
Some(VariableType::Array(_))
));
assert_eq!(response.slot.state, SlotState::UnaryStart);
assert_finite_json(&response);
let text = "some(#, #.qty > 1)";
cursor.pos = text.len() as u32;
let response = ws.slot(&cursor, text).expect("closure over entity array");
assert_finite_json(&response);
let cursor = cursor_at("dt", cell("r1", "c_addr"), 0);
let response = ws.slot(&cursor, "").expect("unary slot over entity object");
assert!(matches!(
response.subject_type,
Some(VariableType::Object(_))
));
assert_finite_json(&response);
for text in [
"claim.address == ",
"claim == ",
"claim.items ",
"claim.address.city",
] {
let cursor = cursor_at("dt", cell("r1", "c_cond"), text.len() as u32);
let response = ws.slot(&cursor, text).expect(text);
assert_eq!(response.role, SlotRole::Condition);
assert_finite_json(&response);
}
let cursor = cursor_at("dt", cell("r1", "c_cond"), "claim.address == ".len() as u32);
let response = ws
.slot(&cursor, "claim.address == ")
.expect("object comparison");
assert_eq!(response.slot.state, SlotState::Value);
assert!(matches!(
response.slot.expected,
Some(VariableType::Object(_))
));
let json = assert_finite_json(&response);
assert!(
json.contains("\"city\""),
"entity fields survive the cut: {json}"
);
}
#[test]
fn inspect_and_completions_survive_mutually_referencing_entities() {
let ws = cyclic_entity_workspace();
let cursor = cursor_at("ex", expression("ex"), 2);
let result = ws.inspect(&cursor).expect("inspect on entity root");
assert!(matches!(result.kind, VariableType::Object(_)));
assert_finite_json(&result);
let cursor = cursor_at("dt", cell("r1", "c_cond"), 0);
let completions = ws.completions(&cursor);
assert!(completions.iter().any(|c| c.label == "claim"));
assert_finite_json(&completions);
let facts = ws.facts("policy");
assert!(facts.iter().any(|f| f.block_id.as_ref() == "dt"));
assert_finite_json(&facts);
}
fn cursor_at(block: &str, target: CursorTarget, pos: u32) -> Cursor {
let mut cursor = cursor(block, target);
cursor.pos = pos;
cursor
}
fn writer_workspace() -> PolicyWorkspace {
let doc = json!({
"blocks": [
{
"id": "dict1",
"type": "dictionary",
"props": { "data": {
"name": "status",
"entries": [
{ "id": "e0", "value": "open", "label": "Open case" },
{ "id": "e1", "value": "closed", "label": "Closed" }
]
} }
},
{
"id": "dm",
"type": "dataModel",
"props": { "data": {
"name": "claim",
"properties": [
{ "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false },
{ "id": "p2", "name": "kind", "type": "string", "enum": ["auto", "home"], "array": false, "optional": false },
{ "id": "p3", "name": "status", "type": "relationship", "target": "status", "array": false, "optional": false },
{ "id": "p4", "name": "filedAt", "type": "date", "array": false, "optional": false }
]
} },
"children": []
},
{
"id": "dt",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [
{ "id": "in_dict", "name": "Status", "field": "claim.status" },
{ "id": "in_enum", "name": "Kind", "field": "claim.kind" },
{ "id": "in_date", "name": "Filed", "field": "claim.filedAt" },
{ "id": "in_total", "name": "Total", "field": "claim.total" }
],
"outputs": [
{ "id": "out_declared_date", "name": "FiledAt", "field": "claim.filedAt" },
{ "id": "out_band", "name": "Band", "field": "claim.band" }
],
"rules": [
{ "_id": "r1", "in_dict": "\"open\"", "in_enum": "\"auto\"", "in_date": "", "in_total": "", "out_declared_date": "", "out_band": "\"a\"" },
{ "_id": "r2", "in_dict": "", "in_enum": "", "in_date": "", "in_total": "", "out_declared_date": "", "out_band": "\"b\"" }
]
} },
"children": []
},
{ "id": "ex_kind", "type": "expression", "props": { "data": { "key": "claim.kind", "value": "\"auto\"" } } },
{ "id": "ex_total", "type": "expression", "props": { "data": { "key": "claim.total", "value": "claim.amount * 2" } } },
{
"id": "m_status",
"type": "match",
"props": { "data": {
"key": "claim.status",
"arms": [
{ "id": "a1", "condition": "claim.amount > 50", "value": "\"open\"" },
{ "id": "a2", "condition": "", "value": "\"closed\"" }
]
} }
},
{
"id": "as_empty",
"type": "assertion",
"props": { "data": {
"output": "claim.isBig",
"conditions": [
{ "id": "c1", "expression": "claim.amount > 100", "operator": "and", "depth": 0 },
{ "id": "c2", "expression": "", "operator": "and", "depth": 0 }
]
} },
"children": []
}
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy(
"policy",
serde_json::from_value(doc).expect("valid writer fixture"),
);
ws
}
#[test]
fn dictionary_cell_keeps_labels_with_match_writer() {
let ws = writer_workspace();
let response = slot_at(&ws, "dt", cell("r2", "in_dict"), "== \"");
assert_eq!(labels(&response.slot.options), vec!["Open case", "Closed"]);
match response.subject_type.expect("unary subject") {
VariableType::Enum(name, _) => assert_eq!(name.as_deref(), Some("status")),
other => panic!("expected dictionary enum, got {other:?}"),
}
let diagnostics = ws.diagnostics("policy");
assert!(diagnostics
.iter()
.any(|d| d.location.block_id.as_deref() == Some("m_status")
&& matches!(d.code, DiagnosticCode::InputOverride)));
}
#[test]
fn inline_enum_cell_keeps_members_with_expression_writer() {
let ws = writer_workspace();
let scope = scope_of(&ws, &cursor("dt", cell("r2", "in_enum")));
assert_eq!(enum_values(&scope.scope.get("$")), vec!["auto", "home"]);
let response = slot_at(&ws, "dt", cell("r2", "in_enum"), "== \"");
assert_eq!(labels(&response.slot.options), vec!["auto", "home"]);
}
#[test]
fn date_cell_keeps_date_with_output_column_writer() {
let ws = writer_workspace();
let scope = scope_of(&ws, &cursor("dt", cell("r2", "in_date")));
assert_eq!(scope.subject_type(), Some(VariableType::Date));
let facts = ws.facts("policy");
let fact = facts
.iter()
.find(|f| {
f.block_id.as_ref() == "dt"
&& matches!(&f.target, CursorTarget::DecisionTableCell { row, col } if row.as_ref() == "r1" && col.as_ref() == "in_date")
})
.expect("date cell fact");
assert_eq!(fact.subject_type, Some(VariableType::Date));
}
#[test]
fn undeclared_written_path_still_gets_written_type() {
let ws = writer_workspace();
let scope = scope_of(&ws, &cursor("dt", cell("r2", "in_total")));
assert_eq!(scope.subject_type(), Some(VariableType::Number));
let scope = scope_of(&ws, &cursor("dt", cell("r2", "out_band")));
assert_eq!(
enum_values(&scope.expected.expect("sibling union")),
vec!["a", "b"]
);
}
#[test]
fn empty_assertion_condition_resolves_for_completions_and_slot() {
let ws = writer_workspace();
let cursor = cursor_at("as_empty", expression("c2"), 0);
let labels: Vec<String> = ws
.completions(&cursor)
.into_iter()
.map(|c| c.label.to_string())
.collect();
assert!(labels.iter().any(|l| l == "claim"), "{labels:?}");
let response = slot_at(&ws, "as_empty", expression("c2"), "");
assert_eq!(response.slot.state, SlotState::Start);
assert!(response.slot.auto_open);
}
#[test]
fn graph_table_heads_complete_for_outputs_and_fieldless_inputs() {
let ws = graph_workspace();
for col in ["o_dict", "c_condition"] {
let labels: Vec<String> = ws
.completions(&graph_cursor("dt", head(col)))
.into_iter()
.map(|c| c.label.to_string())
.collect();
assert!(labels.iter().any(|l| l == "age"), "{col}: {labels:?}");
}
}
fn sibling_writer_workspace() -> PolicyWorkspace {
let shared = json!({
"blocks": [
{
"id": "dict1",
"type": "dictionary",
"props": { "data": {
"name": "severity",
"entries": [
{ "id": "e0", "value": "low", "label": "Low" },
{ "id": "e1", "value": "high", "label": "High" }
]
} }
},
{
"id": "dm",
"type": "dataModel",
"props": { "data": {
"name": "flight",
"properties": [
{ "id": "p1", "name": "number", "type": "string", "array": false, "optional": false },
{ "id": "p2", "name": "severity", "type": "relationship", "target": "severity", "array": false, "optional": false }
]
} },
"children": []
}
]
});
let rule = json!({
"imports": ["shared"],
"blocks": [
{
"id": "dt",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [
{ "id": "in_cond", "name": "Condition" }
],
"outputs": [
{ "id": "out_triggered", "name": "Triggered", "field": "rules.apu.triggered" }
],
"rules": [
{ "_id": "r1", "in_cond": "flight.number == \"X\"", "out_triggered": "true" },
{ "_id": "r2", "in_cond": "", "out_triggered": "false" }
]
} },
"children": []
},
{ "id": "ex_a", "type": "expression", "props": { "data": { "key": "a", "value": "flight.number + \"!\"" } } },
{ "id": "ex_b", "type": "expression", "props": { "data": { "key": "b", "value": "len(a)" } } }
]
});
let entry = json!({
"imports": ["shared"],
"blocks": [
{ "id": "ex_triggered", "type": "expression", "props": { "data": { "key": "triggeredRules", "value": "rules.apu.triggered ? [\"APU\"] : []" } } },
{ "id": "ex_count", "type": "expression", "props": { "data": { "key": "triggeredCount", "value": "len(triggeredRules)" } } }
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy(
"shared",
serde_json::from_value(shared).expect("valid shared fixture"),
);
ws.set_policy(
"rule",
serde_json::from_value(rule).expect("valid rule fixture"),
);
ws.set_policy(
"entry",
serde_json::from_value(entry).expect("valid entry fixture"),
);
ws
}
fn cursor_in(policy: &str, block: &str, target: CursorTarget) -> Cursor {
Cursor {
policy_path: policy.into(),
block_id: block.into(),
pos: 0,
target,
}
}
fn completion_labels(ws: &Workspace, cursor: &Cursor) -> Vec<String> {
ws.completions(cursor)
.into_iter()
.map(|c| c.label.to_string())
.collect()
}
#[test]
fn sibling_policy_writes_are_hidden_until_scheduled() {
let ws = sibling_writer_workspace();
let table_cell = cursor_in("rule", "dt", cell("r2", "in_cond"));
let labels = completion_labels(&ws, &table_cell);
assert!(labels.iter().any(|l| l == "flight"), "{labels:?}");
assert!(!labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
let scope = scope_of(&ws, &table_cell);
assert_eq!(scope.scope.get("triggeredRules"), VariableType::Any);
assert_eq!(
scope.scope.get("flight").get("number"),
VariableType::String
);
let mut probe = table_cell.clone();
probe.pos = 15;
let response = ws.slot(&probe, "triggeredRules ").expect("slot resolves");
assert_eq!(response.slot.operand, Some(VariableType::Any));
let own_write = cursor_in("rule", "ex_a", expression("ex_a"));
let labels = completion_labels(&ws, &own_write);
assert!(labels.iter().any(|l| l == "flight"), "{labels:?}");
assert!(!labels.iter().any(|l| l == "a"), "{labels:?}");
assert!(!labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
let later_block = cursor_in("rule", "ex_b", expression("ex_b"));
let labels = completion_labels(&ws, &later_block);
assert!(labels.iter().any(|l| l == "a"), "{labels:?}");
assert!(!labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
let mut probe = later_block.clone();
probe.pos = 2;
let response = ws.slot(&probe, "a ").expect("slot resolves");
assert_eq!(response.slot.operand, Some(VariableType::String));
let scope = scope_of(&ws, &later_block);
assert_eq!(scope.scope.get("a"), VariableType::String);
assert_eq!(scope.scope.get("triggeredRules"), VariableType::Any);
let facts = ws.facts("rule");
let cell_fact = facts
.iter()
.find(|f| {
f.block_id.as_ref() == "dt"
&& matches!(&f.target, CursorTarget::DecisionTableCell { row, col } if row.as_ref() == "r1" && col.as_ref() == "in_cond")
})
.expect("condition cell fact");
assert_eq!(cell_fact.role, SlotRole::Condition);
assert_eq!(cell_fact.expected_type, Some(VariableType::Bool));
assert!(facts.iter().any(|f| f.block_id.as_ref() == "ex_b"));
}
#[test]
fn upstream_policy_writes_stay_visible_downstream() {
let ws = sibling_writer_workspace();
let reader = cursor_in("entry", "ex_triggered", expression("ex_triggered"));
let labels = completion_labels(&ws, &reader);
assert!(labels.iter().any(|l| l == "rules"), "{labels:?}");
assert!(labels.iter().any(|l| l == "flight"), "{labels:?}");
assert!(!labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
let scope = scope_of(&ws, &reader);
assert_eq!(
scope.scope.get("rules").get("apu").get("triggered"),
VariableType::Bool
);
let after_write = cursor_in("entry", "ex_count", expression("ex_count"));
let labels = completion_labels(&ws, &after_write);
assert!(labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
let mut probe = after_write.clone();
probe.pos = 15;
let response = ws.slot(&probe, "triggeredRules ").expect("slot resolves");
assert_eq!(
response.slot.operand,
Some(VariableType::Array(
VariableType::Const("APU".into()).into()
))
);
}
#[test]
fn completions_agree_with_undefined_variable_diagnostics() {
let mut ws = sibling_writer_workspace();
let rule = json!({
"imports": ["shared"],
"blocks": [
{
"id": "dt",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [
{ "id": "in_cond", "name": "Condition" }
],
"outputs": [
{ "id": "out_triggered", "name": "Triggered", "field": "rules.apu.triggered" }
],
"rules": [
{ "_id": "r1", "in_cond": "len(triggeredRules) > 0", "out_triggered": "true" }
]
} },
"children": []
}
]
});
ws.set_policy(
"rule",
serde_json::from_value(rule).expect("valid rule fixture"),
);
let diagnostics = ws.diagnostics("rule");
assert!(
diagnostics
.iter()
.any(|d| d.location.block_id.as_deref() == Some("dt")
&& matches!(d.code, DiagnosticCode::UndefinedVariable)),
"{diagnostics:?}"
);
let labels = completion_labels(&ws, &cursor_in("rule", "dt", cell("r1", "in_cond")));
assert!(!labels.iter().any(|l| l == "triggeredRules"), "{labels:?}");
assert!(labels.iter().any(|l| l == "flight"), "{labels:?}");
}
#[test]
fn slot_expression_after_conditional_literal_union_offers_both_branches() {
let doc = json!({
"blocks": [
{ "id": "ex_g", "type": "expression", "props": { "data": { "key": "g", "value": "true ? \"hello\" : \"world\"" } } },
{ "id": "ex_h", "type": "expression", "props": { "data": { "key": "h", "value": "g == " } } }
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy("policy", serde_json::from_value(doc).unwrap());
let scope = scope_of(&ws, &cursor("ex_h", expression("ex_h")));
assert_eq!(
scope.scope.get("g"),
VariableType::Enum(None, vec!["hello".into(), "world".into()])
);
let response = slot_at(&ws, "ex_h", expression("ex_h"), "g == ");
assert_eq!(response.slot.state, SlotState::Value);
assert_eq!(labels(&response.slot.options), vec!["hello", "world"]);
}
#[test]
fn slot_scope_of_unparsable_block_keeps_earlier_independent_writes() {
let doc = json!({
"blocks": [
{ "id": "b1", "type": "expression", "props": { "data": { "key": "a", "value": "1" } } },
{ "id": "b2", "type": "expression", "props": { "data": { "key": "b", "value": "\"x\"" } } },
{ "id": "b3", "type": "expression", "props": { "data": { "key": "c", "value": "(" } } },
{ "id": "b4", "type": "expression", "props": { "data": { "key": "d", "value": "2" } } }
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy("policy", serde_json::from_value(doc).unwrap());
let scope = scope_of(&ws, &cursor("b3", expression("b3")));
assert_eq!(scope.scope.get("a"), VariableType::Number);
assert_eq!(scope.scope.get("b"), VariableType::Const("x".into()));
assert_eq!(scope.scope.get("d"), VariableType::Any);
}
#[test]
fn slot_and_completions_expose_closure_locals_in_expression_block() {
let text = "map(m as x, map(x.tags as y, ";
let doc = json!({
"blocks": [
{ "id": "ex_m", "type": "expression", "props": { "data": { "key": "m", "value": "[{a: 10, tags: [\"x\"]}, {a: 20, tags: [\"y\"]}]" } } },
{ "id": "ex_n", "type": "expression", "props": { "data": { "key": "n", "value": text } } }
]
});
let mut ws = PolicyWorkspace::new();
ws.set_policy("policy", serde_json::from_value(doc).unwrap());
let response = slot_at(&ws, "ex_n", expression("ex_n"), text);
assert_eq!(response.slot.state, SlotState::Closure);
let names: Vec<&str> = response
.slot
.locals
.iter()
.map(|l| l.name.as_str())
.collect();
assert_eq!(names, ["y", "x"]);
assert_eq!(
response.slot.locals[0].kind,
VariableType::Enum(None, vec!["x".into(), "y".into()])
);
assert!(matches!(
response.slot.locals[1].kind,
VariableType::Object(_)
));
let mut cursor = cursor("ex_n", expression("ex_n"));
cursor.pos = text.len() as u32;
let labels: Vec<String> = ws
.completions(&cursor)
.into_iter()
.map(|c| c.label.to_string())
.collect();
assert_eq!(&labels[..3], ["y", "x", "m"]);
let response = slot_at(&ws, "ex_n", expression("ex_n"), "map(m, #");
assert_eq!(response.slot.locals.len(), 1);
assert_eq!(response.slot.locals[0].name, "#");
assert_eq!(response.slot.replace_span, (7, 8));
}
#[test]
fn value_slots_list_only_fields_that_can_fit_the_expected_scalar() {
let ws = graph_workspace();
let labels = |cursor: &Cursor| -> Vec<String> {
ws.completions(cursor)
.into_iter()
.filter(|c| c.kind == CompletionKind::Variable)
.map(|c| c.label.to_string())
.collect()
};
// `> |` in a number cell: `age` fits, `name` cannot.
let mut cursor = graph_cursor("dt", cell("r1", "c_field"));
cursor.pos = 2;
let after_gt = labels(&cursor);
assert!(after_gt.iter().any(|l| l == "age"), "{after_gt:?}");
assert!(!after_gt.iter().any(|l| l == "name"), "{after_gt:?}");
// A condition start expects a bool but any field may open a comparison.
let cursor = graph_cursor("dt", cell("r2", "c_condition"));
let at_start = labels(&cursor);
assert!(at_start.iter().any(|l| l == "name"), "{at_start:?}");
// A schema-typed output cell filters like a comparison; a mixed one keeps everything.
let typed = labels(&graph_cursor("dt", cell("r1", "o_schema")));
assert!(!typed.iter().any(|l| l == "name"), "{typed:?}");
let mixed = labels(&graph_cursor("dt_untyped", cell("u3", "o_mixed")));
assert!(mixed.iter().any(|l| l == "name"), "{mixed:?}");
}
#[test]
fn operator_positions_offer_no_fields() {
let ws = graph_workspace();
// `age > 1|`: only an operator can follow, so the field list is empty.
let mut cursor = graph_cursor("dt", cell("r1", "c_condition"));
cursor.pos = 7;
assert!(ws.completions(&cursor).is_empty());
// `age >|`: the glued operator keeps the field list away too.
cursor.pos = 5;
assert!(ws.completions(&cursor).is_empty());
// `age|`: the word itself still completes.
cursor.pos = 3;
assert!(ws.completions(&cursor).iter().any(|c| c.label == "age"));
}
#[test]
fn accepted_fields_know_what_follows_them() {
let follow = |ws: &Workspace, cursor: &Cursor, label: &str| -> Option<String> {
ws.completions(cursor)
.into_iter()
.find(|c| c.label == label)
.and_then(|c| c.follow.map(String::from))
};
let ws = graph_workspace();
// Condition cell at its start: an object continues with `.`, a leaf takes a space and an operator.
let cursor = graph_cursor("dt", cell("r2", "c_condition"));
assert_eq!(follow(&ws, &cursor, "age").as_deref(), Some(" "));
assert_eq!(follow(&ws, &cursor, "$nodes").as_deref(), Some("."));
// An output value never chains into a comparison.
let cursor = graph_cursor("dt", cell("r1", "o_plain"));
assert_eq!(follow(&ws, &cursor, "age"), None);
// A policy unary cell chains; a table head (path) does not.
let ws = policy_workspace();
let at = cursor_at("dt", cell("r3", "in_condition"), 0);
assert_eq!(follow(&ws, &at, "customer").as_deref(), Some("."));
let at = cursor_at("dt", head("out_union"), 0);
assert_eq!(follow(&ws, &at, "customer").as_deref(), Some("."));
}
+46 -6
View File
@@ -5,7 +5,9 @@ use crate::functions::{
};
use crate::intellisense::IntelliSenseToken;
use crate::variable::VariableType;
use ahash::HashMap;
use serde::Serialize;
use std::rc::Rc;
use strum::IntoEnumIterator;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@@ -28,6 +30,12 @@ pub struct Completion {
pub boost: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub method_for: Option<VariableType>,
/// Type of the field or local a variable or property completion names.
#[serde(skip)]
pub var_type: Option<VariableType>,
/// Text an editor appends after accepting the item: `.` into an object, a space before an operator.
#[serde(skip_serializing_if = "Option::is_none")]
pub follow: Option<&'static str>,
}
pub struct Completions;
@@ -38,6 +46,7 @@ impl Completions {
pos: u32,
data: &VariableType,
tokens: &[IntelliSenseToken],
locals: &[(Rc<str>, VariableType)],
) -> Vec<Completion> {
let before = source.get(..pos as usize).unwrap_or(source);
let prefix = Self::extract_prefix(before);
@@ -58,7 +67,7 @@ impl Completions {
Self::build_property(&target_type)
}
None => Self::build_scope(data),
None => Self::build_scope(data, locals),
};
Self::filter(completions, prefix)
@@ -72,8 +81,7 @@ impl Completions {
};
if let VariableType::Object(obj) = resolved {
let obj = obj.borrow();
for (key, val) in obj.iter() {
for (key, val) in Self::sorted_fields(&obj.borrow()) {
completions.push(Completion {
label: key.to_string(),
kind: CompletionKind::Property,
@@ -81,6 +89,8 @@ impl Completions {
info: String::new(),
boost: Some(10),
method_for: None,
var_type: Some(val.shallow_clone()),
follow: None,
});
}
}
@@ -101,17 +111,30 @@ impl Completions {
completions
}
pub fn build_scope(data: &VariableType) -> Vec<Completion> {
/// `locals` are closure-bound names (innermost first); they lead the list above the fields.
pub fn build_scope(data: &VariableType, locals: &[(Rc<str>, VariableType)]) -> Vec<Completion> {
let mut completions = Vec::new();
for (name, kind) in locals {
completions.push(Completion {
label: name.to_string(),
kind: CompletionKind::Variable,
detail: kind.to_string(),
info: String::new(),
boost: Some(30),
method_for: None,
var_type: Some(kind.shallow_clone()),
follow: None,
});
}
let resolved_data = match data {
VariableType::Nullable(inner) => inner.as_ref(),
other => other,
};
if let VariableType::Object(obj) = resolved_data {
let obj = obj.borrow();
for (key, val) in obj.iter() {
for (key, val) in Self::sorted_fields(&obj.borrow()) {
completions.push(Completion {
label: key.to_string(),
kind: CompletionKind::Variable,
@@ -119,6 +142,8 @@ impl Completions {
info: String::new(),
boost: Some(20),
method_for: None,
var_type: Some(val.shallow_clone()),
follow: None,
});
}
}
@@ -130,6 +155,8 @@ impl Completions {
info: String::new(),
boost: Some(-10),
method_for: None,
var_type: None,
follow: None,
});
completions.extend(
@@ -142,6 +169,15 @@ impl Completions {
completions
}
// Alphabetical, case-insensitive, `$`-prefixed roots last: the map iterates in hash order.
fn sorted_fields(fields: &HashMap<Rc<str>, VariableType>) -> Vec<(&Rc<str>, &VariableType)> {
let mut out: Vec<_> = fields.iter().collect();
out.sort_by_cached_key(|(key, _)| {
(key.starts_with('$'), key.to_lowercase(), key.to_string())
});
out
}
fn function(fk: FunctionKind, boost_override: Option<i32>) -> Completion {
let label = fk.to_string();
let info = function_info(&fk);
@@ -159,6 +195,8 @@ impl Completions {
info,
boost,
method_for: None,
var_type: None,
follow: None,
}
}
@@ -174,6 +212,8 @@ impl Completions {
info,
boost: None,
method_for,
var_type: None,
follow: None,
}
}
+3 -2
View File
@@ -92,12 +92,13 @@ impl IntelliSense {
pos: u32,
data: &VariableType,
) -> Vec<completion::Completion> {
let locals = self.closure_locals(source, pos, data);
let tokens = match self.type_check(source, data) {
Some(t) => t,
None => return Completions::build_scope(data),
None => return Completions::build_scope(data, &locals),
};
Completions::build(source, pos, data, &tokens)
Completions::build(source, pos, data, &tokens, &locals)
}
pub fn inspect(
+17 -15
View File
@@ -337,7 +337,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
TokenKind::TemplateString(template) => match template {
TemplateString::ExpressionStart => {
self.next();
nodes.push(expression_parser(ParserContext::Global));
nodes.push(expression_parser(ParserContext::Nested));
if let Some(error) =
self.expect(TokenKind::TemplateString(TemplateString::ExpressionEnd))
@@ -404,7 +404,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
break;
}
arguments.push(expression_parser(ParserContext::Global));
arguments.push(expression_parser(ParserContext::Nested));
if self.current_kind() != Some(&TokenKind::Operator(Operator::Comma)) {
break;
}
@@ -520,7 +520,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
c = cc;
if c.kind != TokenKind::Bracket(Bracket::RightSquareBracket) {
to = Some(expression_parser(ParserContext::Global));
to = Some(expression_parser(ParserContext::Nested));
}
expect!(self, TokenKind::Bracket(Bracket::RightSquareBracket));
@@ -531,7 +531,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
),
})
} else {
let from_node = expression_parser(ParserContext::Global);
let from_node = expression_parser(ParserContext::Nested);
from = Some(from_node);
let Some(cc) = self.current() else {
return self.error_with_node(
@@ -568,7 +568,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
c = cc;
if c.kind != TokenKind::Bracket(Bracket::RightSquareBracket) {
to = Some(expression_parser(ParserContext::Global));
to = Some(expression_parser(ParserContext::Nested));
}
expect!(self, TokenKind::Bracket(Bracket::RightSquareBracket));
@@ -737,7 +737,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
FunctionKind::Closure(_) => {
let mut arguments = BumpVec::new_in(&self.bump);
arguments.push(expression_parser(ParserContext::Global));
arguments.push(expression_parser(ParserContext::Nested));
let alias: Option<&'arena str> =
if self
@@ -790,7 +790,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
break;
}
arguments.push(expression_parser(ParserContext::Global));
arguments.push(expression_parser(ParserContext::Nested));
if self.current_kind() != Some(&TokenKind::Operator(Operator::Comma)) {
break;
}
@@ -843,7 +843,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
};
self.next();
let left = expression_parser(ParserContext::Global);
let left = expression_parser(ParserContext::Nested);
if left.has_error() {
self.set_position(initial_position);
return None;
@@ -854,7 +854,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
return None;
};
let right = expression_parser(ParserContext::Global);
let right = expression_parser(ParserContext::Nested);
if right.has_error() {
self.set_position(initial_position);
return None;
@@ -917,7 +917,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
}
}
nodes.push(expression_parser(ParserContext::Global));
nodes.push(expression_parser(ParserContext::Nested));
}
expect!(self, TokenKind::Bracket(Bracket::RightSquareBracket));
@@ -965,7 +965,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
expect!(self, TokenKind::Operator(Operator::Assign));
let mut key_value_pairs = BumpVec::new_in(self.bump);
let value = expression_parser(ParserContext::Global);
let value = expression_parser(ParserContext::Nested);
key_value_pairs.push((transform_key(starting_key), value));
let mut checkpoint_for_return = None;
@@ -1005,14 +1005,14 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
);
}
let value = expression_parser(ParserContext::Global);
let value = expression_parser(ParserContext::Nested);
key_value_pairs.push((transform_key(key_node), value));
}
let mut output = None;
if let Some(starting_position) = checkpoint_for_return {
self.set_position(starting_position);
let value = expression_parser(ParserContext::Global);
let value = expression_parser(ParserContext::Nested);
output.replace(value);
}
@@ -1048,7 +1048,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
loop {
let key = self.object_key(&expression_parser);
expect!(self, TokenKind::Operator(Operator::Slice));
let value = expression_parser(ParserContext::Global);
let value = expression_parser(ParserContext::Nested);
key_value_pairs.push((key, value));
@@ -1133,7 +1133,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
TokenKind::Bracket(bracket) => match bracket {
Bracket::LeftSquareBracket => {
expect!(self, TokenKind::Bracket(Bracket::LeftSquareBracket));
let token = expression_parser(ParserContext::Global);
let token = expression_parser(ParserContext::Nested);
expect!(self, TokenKind::Bracket(Bracket::RightSquareBracket));
token
@@ -1348,5 +1348,7 @@ impl<'a, 'arena> MetadataHelper<'a, 'arena> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParserContext {
Global,
/// Inside brackets, arguments or a template: unary clause joiners are ordinary operators here.
Nested,
Closure,
}
+1 -1
View File
@@ -247,7 +247,7 @@ impl<'arena, 'token_ref> Parser<'arena, 'token_ref, Unary> {
let p_start = self.current().map(|s| s.span.0);
self.next();
let binary_node = self.binary_expression(0, ParserContext::Global);
let binary_node = self.binary_expression(0, ParserContext::Nested);
if let Some(error_node) = self.expect(TokenKind::Bracket(Bracket::RightParenthesis)) {
return error_node;
};
+183 -12
View File
@@ -12,10 +12,10 @@ use crate::parser::Parser;
use crate::variable::VariableType;
use super::literals::{declared, item_expectation, NodeTable};
use super::operators::{nullable_extras, operators_for, LOGICAL};
use super::operators::{nullable_extras, operators_for, EQUALITY, LOGICAL};
use super::{
enum_values, is_null_option, null_option, subject_enum_options, LabelResolver, Parsed, Slot,
SlotRole, SlotState, Span,
enum_values, is_null_option, null_option, subject_enum_options, LabelResolver, Local, Parsed,
Slot, SlotRole, SlotState, Span, NULL_SOURCE,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -95,6 +95,22 @@ pub(crate) fn classify(
ctx.run(pos)
}
pub(crate) fn closure_locals(
parsed: &Parsed,
table: &NodeTable,
pos: u32,
) -> Vec<(Rc<str>, VariableType)> {
let ctx = Ctx::new(parsed, table, false, SlotRole::Condition, None, None);
let limit = match ctx.string_at(pos) {
Some((idx, ..)) => idx,
None => match ctx.word_at(pos) {
Some(w) => ctx.not_in_head(w),
None => ctx.operand_limit(pos),
},
};
ctx.locals(limit)
}
pub(crate) fn fallback(
source: &str,
pos: u32,
@@ -387,10 +403,12 @@ impl<'p, 'a> Ctx<'p, 'a> {
self.text(item.body).to_lowercase()
};
let mut slot = self.at(item.span.0, idx);
slot.locals = self.slot_locals(idx);
if matches!(slot.state, SlotState::UnaryStart | SlotState::ListElement) {
self.unlist(&mut slot, self.text(item.body));
}
slot.options.retain(|o| !is_null_option(o));
slot.operators.clear();
if !body.is_empty() {
slot.options.retain(|o| {
o.value.to_lowercase().starts_with(&body)
@@ -406,6 +424,15 @@ impl<'p, 'a> Ctx<'p, 'a> {
return slot;
}
if let Some(slot) = self
.items
.iter()
.position(|it| it.span.1 == pos)
.and_then(|p| self.operator_prefix(p))
{
return slot;
}
let word = self.word_at(pos);
let (eff, limit, replace) = match word {
Some(w) => {
@@ -416,18 +443,12 @@ impl<'p, 'a> Ctx<'p, 'a> {
(self.items[first].span.0, self.items[w].span.1),
)
}
None => (
pos,
self.items
.iter()
.position(|it| it.span.1 > pos || (it.span.0 == it.span.1 && it.span.0 >= pos))
.unwrap_or(self.items.len()),
(pos, pos),
),
None => (pos, self.operand_limit(pos), (pos, pos)),
};
let inside = word.is_none() && self.items.get(limit).is_some_and(|it| it.span.0 < pos);
let mut slot = self.at(eff, limit);
slot.replace_span = replace;
slot.locals = self.slot_locals(limit);
if inside {
slot.auto_open = false;
}
@@ -504,6 +525,90 @@ impl<'p, 'a> Ctx<'p, 'a> {
slot.listed = listed;
}
/// Index of the first item at or after `pos` when the caret is not inside a word.
fn operand_limit(&self, pos: u32) -> usize {
self.items
.iter()
.position(|it| it.span.1 > pos || (it.span.0 == it.span.1 && it.span.0 >= pos))
.unwrap_or(self.items.len())
}
fn slot_locals(&self, limit: usize) -> Vec<Local> {
self.locals(limit)
.into_iter()
.map(|(name, kind)| Local {
name: name.to_string(),
kind,
})
.collect()
}
/// Names bound by enclosing closure bodies, innermost first; a shadowed name keeps the inner binding.
fn locals(&self, limit: usize) -> Vec<(Rc<str>, VariableType)> {
let frames = self.frames(limit);
let mut out: Vec<(Rc<str>, VariableType)> = Vec::new();
for frame in frames.iter().rev() {
let FrameKind::Call {
name,
method: false,
} = frame.kind
else {
continue;
};
let closure = matches!(
FunctionKind::try_from(self.text(self.items[name].span)),
Ok(FunctionKind::Closure(_))
);
if !closure || frame.commas == 0 {
continue;
}
let name: Rc<str> = self
.collection_end(frame)
.and_then(|end| self.closure_alias(end))
.map(Rc::from)
.unwrap_or_else(|| Rc::from("#"));
if out.iter().any(|(n, _)| *n == name) {
continue;
}
let element = self.element_type(frame).unwrap_or(VariableType::Any);
out.push((name, element));
}
out
}
/// End of a closure's collection run, stopping before an `as` alias.
fn collection_end(&self, frame: &Frame) -> Option<usize> {
let end = self.run_end(frame.open + 1)?;
let mut depth = 0u32;
for i in frame.open + 2..=end {
match self.items[i].kind {
Kind::Open(_) | Kind::TemplateOpen => depth += 1,
Kind::Close(_) | Kind::TemplateClose => depth = depth.saturating_sub(1),
Kind::Ident
if depth == 0
&& self.items[i - 1].kind != Kind::Op(Operator::Dot)
&& self.text(self.items[i].span) == "as" =>
{
return Some(i - 1);
}
_ => {}
}
}
Some(end)
}
fn closure_alias(&self, end: usize) -> Option<&str> {
let as_word = self.items.get(end + 1)?;
if as_word.kind != Kind::Ident || self.text(as_word.span) != "as" {
return None;
}
let alias = self
.items
.get(end + 2)
.filter(|it| it.kind == Kind::Ident)?;
Some(self.text(alias.span))
}
fn word_at(&self, pos: u32) -> Option<usize> {
self.items
.iter()
@@ -960,6 +1065,11 @@ impl<'p, 'a> Ctx<'p, 'a> {
slot.listed = self.unary_listed();
slot.options = subject_enum_options(&subject, self.labels).unwrap_or_default();
slot.options.retain(|o| !slot.listed.contains(&o.value));
if matches!(subject, VariableType::Nullable(_))
&& !slot.listed.iter().any(|v| v == NULL_SOURCE)
{
slot.options.push(null_option());
}
slot.operators = operators_for(&subject, true);
slot.auto_open = !slot.options.is_empty() || !slot.operators.is_empty();
slot.expected = Some(subject.shallow_clone());
@@ -992,6 +1102,42 @@ impl<'p, 'a> Ctx<'p, 'a> {
.is_none_or(|k| !is_operand_end(self.items[k].kind))
}
/// The caret glued to a comparison operator that begins longer ones (`>|` before `>=`,
/// `not|` before `not in`): those operators stay on offer, replacing the typed one.
fn operator_prefix(&self, p: usize) -> Option<Slot> {
if !matches!(
self.items[p].kind,
Kind::Op(Operator::Comparison(_) | Operator::Assign | Operator::QuestionMark)
| Kind::Op(Operator::Logical(LogicalOperator::Not))
) {
return None;
}
// `not` and `in` are words and reach the operator list through prefix filtering.
let typed = self.text(self.items[p].span);
if typed.chars().all(|c| c.is_alphabetic()) {
return None;
}
let operand = if self.implicit_subject(p) {
Some(self.subject())
} else {
self.left_operand(p)
};
// The full list: a unary text cell hides `==` behind the bare value, but a typed `=` wants it.
let operators: Vec<&'static str> =
operators_for(operand.as_ref().unwrap_or(&VariableType::Any), false)
.into_iter()
.filter(|op| op.starts_with(typed))
.collect();
if operators.len() < 2 && operators.first().is_none_or(|op| *op == typed) {
return None;
}
let mut slot = Slot::new(SlotState::Operator, self.items[p].span);
slot.operators = operators;
slot.operand = operand;
slot.auto_open = true;
Some(slot)
}
fn value_after(&self, p: usize, op: ComparisonOperator) -> Slot {
let left = if self.implicit_subject(p) {
Some(self.subject())
@@ -1019,6 +1165,11 @@ impl<'p, 'a> Ctx<'p, 'a> {
{
let start = self.run_start(end, false);
slot.operand = self.type_of_run(start, end, true);
// The whole path stands where its first segment does: that slot's expectation applies.
let head = self.at(self.items[start].span.0, start);
if head.wanted_scalar().is_some() && head.state != SlotState::Member {
slot.expected = head.expected;
}
}
slot
}
@@ -1213,7 +1364,7 @@ impl<'p, 'a> Ctx<'p, 'a> {
}
fn element_type(&self, frame: &Frame) -> Option<VariableType> {
let end = self.run_end(frame.open + 1)?;
let end = self.collection_end(frame)?;
let collection = self.type_of_run(frame.open + 1, end, false)?;
collection.iterator().map(|t| t.as_ref().shallow_clone())
}
@@ -1294,6 +1445,16 @@ impl<'p, 'a> Ctx<'p, 'a> {
slot
}
/// A bare field path: identifiers and dots only, no literal, call or bracket.
fn is_field_run(&self, start: usize, end: usize) -> bool {
self.items[start..=end].iter().all(|it| {
matches!(
it.kind,
Kind::Ident | Kind::Ref(_) | Kind::Op(Operator::Dot)
)
})
}
fn after_operand(&self, p: usize, gap: bool, frames: &[Frame]) -> Slot {
let start = self.run_start(p, true);
let before = start.checked_sub(1).map(|k| self.items[k].kind);
@@ -1320,6 +1481,16 @@ impl<'p, 'a> Ctx<'p, 'a> {
let nullable = !after_comparison && matches!(operand, Some(VariableType::Nullable(_)));
if nullable {
slot.operators.extend(nullable_extras(self.unary));
} else if bool_clause
&& !after_comparison
&& !matches!(
before,
Some(Kind::Op(Operator::Logical(LogicalOperator::Not)))
)
&& self.is_field_run(start, p)
{
// A bool field reads as a condition on its own, but comparing it is common too.
slot.operators.extend(EQUALITY);
}
slot.auto_open = gap;
return slot;
+82
View File
@@ -56,6 +56,14 @@ pub enum SlotState {
Path,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Local {
pub name: String,
#[serde(rename = "type")]
pub kind: VariableType,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Slot {
@@ -70,9 +78,28 @@ pub struct Slot {
pub in_string: Option<char>,
pub listed: Vec<String>,
pub auto_open: bool,
pub locals: Vec<Local>,
}
impl Slot {
/// The scalar a field written here must produce: set when the slot asks for a value of a
/// known scalar type, so a field list can drop the fields that could never fit.
pub fn wanted_scalar(&self) -> Option<&VariableType> {
if !matches!(
self.state,
SlotState::Value
| SlotState::UnaryStart
| SlotState::ListElement
| SlotState::Range
| SlotState::Argument
| SlotState::Member
) {
return None;
}
let (t, _) = self.expected.as_ref()?.unwrap_nullable();
scalar_class(t).map(|_| t)
}
pub(crate) fn new(state: SlotState, replace_span: Span) -> Self {
Self {
state,
@@ -86,6 +113,7 @@ impl Slot {
in_string: None,
listed: Vec::new(),
auto_open: false,
locals: Vec::new(),
}
}
}
@@ -371,6 +399,30 @@ impl IntelliSense {
}
}
/// Closure-bound names visible at byte `pos`, innermost first: `x` for `map(m as x, ...)`,
/// `#` for an unaliased closure, each with the element type.
pub fn closure_locals(
&mut self,
source: &str,
pos: u32,
scope: &VariableType,
) -> Vec<(Rc<str>, VariableType)> {
self.arena.reset();
let pos = clamp_pos(source, pos);
let Some(parsed) = parse_partial(
&self.arena,
&mut self.lexer,
self.strict,
source,
false,
scope,
) else {
return Vec::new();
};
let table = NodeTable::build(&parsed, false, None);
classify::closure_locals(&parsed, &table, pos)
}
/// Literal facts only (bulk projection, no caret).
pub fn literals(
&mut self,
@@ -403,3 +455,33 @@ fn clamp_pos(source: &str, pos: u32) -> u32 {
}
pos as u32
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum ScalarClass {
Bool,
Number,
String,
Date,
}
fn scalar_class(t: &VariableType) -> Option<ScalarClass> {
match t {
VariableType::Bool => Some(ScalarClass::Bool),
VariableType::Number => Some(ScalarClass::Number),
VariableType::String | VariableType::Const(_) | VariableType::Enum(..) => {
Some(ScalarClass::String)
}
VariableType::Date => Some(ScalarClass::Date),
_ => None,
}
}
/// Whether a field of type `field` can stand where `wanted` is expected: scalars must match in
/// kind, while objects, arrays and untyped values may still lead to a fitting path.
pub fn field_fits(field: &VariableType, wanted: &VariableType) -> bool {
let (field, _) = field.unwrap_nullable();
match scalar_class(field) {
Some(class) => scalar_class(wanted) == Some(class),
None => !matches!(field, VariableType::Null | VariableType::Interval),
}
}
+9 -2
View File
@@ -4,7 +4,7 @@ const ORDERED: &[&str] = &["==", "!=", "<", "<=", ">", ">=", "in", "not in"];
const ORDERED_UNARY: &[&str] = &[">", ">=", "<", "<=", "==", "!=", "in", "not in"];
const TEXT: &[&str] = &["==", "!=", "in", "not in"];
const TEXT_UNARY: &[&str] = &["!=", "in", "not in"];
const EQUALITY: &[&str] = &["==", "!="];
pub(crate) const EQUALITY: &[&str] = &["==", "!="];
const MEMBERSHIP: &[&str] = &["in", "not in"];
/// Operators a nullable operand adds on top of its inner type's (`??` has no unary form).
@@ -20,9 +20,16 @@ pub(crate) fn nullable_extras(unary: bool) -> &'static [&'static str] {
pub(crate) fn operators_for(t: &VariableType, unary: bool) -> Vec<&'static str> {
if let VariableType::Nullable(inner) = t {
let mut list = operators_for(inner, unary);
let mut front = 0;
for op in nullable_extras(unary) {
if !list.contains(op) {
if list.contains(op) {
continue;
}
if *op == "??" {
list.push(op);
} else {
list.insert(front, op);
front += 1;
}
}
return list;
+1 -1
View File
@@ -61,7 +61,7 @@ fn operators_by_type() {
);
assert_eq!(
operators_for(&VariableType::Nullable(Rc::new(status.clone())), true),
vec!["!=", "in", "not in", "=="]
vec!["==", "!=", "in", "not in"]
);
assert_eq!(
operators_for(&VariableType::Const(Rc::from("hello")), false),
+97
View File
@@ -648,3 +648,100 @@ fn analysis_disjoint_enum_equality_hint_fires() {
}
}
}
#[test]
fn completions_order_is_sorted_and_stable() {
let mut is = IntelliSense::new();
let data: VariableType = serde_json::from_str(
r#"{"Object":{"zeta":"Number","Alpha":"String","beta":"Bool","$":"Number","gamma":{"Object":{"y":"Number","X":"String","a":"Bool"}}}}"#,
)
.unwrap();
let first: Vec<String> = is
.completions("", 0, &data)
.into_iter()
.map(|c| c.label)
.collect();
let second: Vec<String> = is
.completions("", 0, &data)
.into_iter()
.map(|c| c.label)
.collect();
assert_eq!(first, second);
assert_eq!(
&first[..6],
["Alpha", "beta", "gamma", "zeta", "$", "$root"]
);
assert_eq!(
first[6], "len",
"functions keep registry order after the variables"
);
let props: Vec<String> = is
.completions("gamma.", 6, &data)
.into_iter()
.filter(|c| c.kind == zen_expression::intellisense::completion::CompletionKind::Property)
.map(|c| c.label)
.collect();
assert_eq!(props, ["a", "X", "y"]);
}
#[test]
fn completions_offer_closure_locals_first() {
let mut is = IntelliSense::new();
let data: VariableType = serde_json::from_str(
r#"{"Object":{"i":{"Array":"String"},"m":{"Array":{"Object":{"a":"Number","tags":{"Array":"String"}}}}}}"#,
)
.unwrap();
let mut labels = |source: &str| -> Vec<(String, String)> {
is.completions(source, source.len() as u32, &data)
.into_iter()
.map(|c| (c.label, c.detail))
.collect()
};
let got = labels("map(m as x, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("x", "object"));
assert_eq!(got[1].0, "i");
assert_eq!(got[2].0, "m");
let got = labels("map(m as x, x");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["x"]
);
let got = labels("map(m, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("#", "object"));
let got = labels("map(m, #");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["#"]
);
let got = labels("map(m as x, map(x.tags as y, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("y", "string"));
assert_eq!(got[1].0, "x");
assert_eq!(got[2].0, "i");
let got = labels("map(m as x, map(x.tags as y, y");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["y"]
);
let got = labels("filter(m as x, x.a > ");
assert_eq!(got[0].0, "x");
let got = labels("map(m as x, x.a) + ");
assert_eq!(got[0].0, "i");
assert!(got.iter().all(|(l, _)| l != "x"));
let boost = IntelliSense::new()
.completions("map(m as x, ", 12, &data)
.into_iter()
.find(|c| c.label == "x")
.and_then(|c| c.boost);
assert_eq!(boost, Some(30));
}
@@ -48,6 +48,16 @@ return_type = '{"Enum":[null,["high","low"]]}'
[test.strict]
return_type = '{"Enum":[null,["high","low"]]}'
[[test]]
name = "ternary over two string literals produces an anonymous enum"
expression = "true ? 'hello' : 'world'"
[test.loose]
return_type = '{"Enum":[null,["hello","world"]]}'
[test.strict]
return_type = '{"Enum":[null,["hello","world"]]}'
[[test]]
name = "nested ternary produces enum"
expression = "x > 100 ? 'high' : x > 50 ? 'mid' : 'low'"
@@ -493,6 +493,17 @@ return_type = '"Number"'
[test.strict]
return_type = '"Number"'
[[test]]
name = "coalesce: Nullable(Const) ?? const merges into an enum"
expression = "kind ?? 'world'"
input = '{"Object": {"kind": {"Nullable": {"Const": "hello"}}}}'
[test.loose]
return_type = '{"Enum":[null,["hello","world"]]}'
[test.strict]
return_type = '{"Enum":[null,["hello","world"]]}'
[[test]]
name = "coalesce: non-nullable passes through"
expression = "name ?? 'fallback'"
+60 -21
View File
@@ -68,7 +68,7 @@ condition;false;;bool;d() |;operator;;;==,!=,<,<=,>,>=,in,not in;4..4;true
condition;false;;bool;d("2024-01-01") |;operator;;;==,!=,<,<=,>,>=,in,not in;16..16;true
condition;false;;bool;len(name) |;operator;;;==,!=,<,<=,>,>=,in,not in;10..10;true
condition;false;;bool;age + 1 |;operator;;;==,!=,<,<=,>,>=,in,not in;8..8;true
condition;false;;bool;active |;logical;bool;;and,or;7..7;true
condition;false;;bool;active |;logical;bool;;and,or,==,!=;7..7;true
condition;false;;bool;not active |;logical;bool;;and,or;11..11;true
condition;false;;bool;(age > 1) |;logical;bool;;and,or;10..10;true
condition;false;;bool;age > 1 |;logical;bool;;and,or;8..8;true
@@ -120,16 +120,21 @@ condition;false;;bool;some(items, #.price > 1 |;logical;bool;;and,or;24..24;true
condition;false;;bool;some(items, #.price |;operator;;;==,!=,<,<=,>,>=,in,not in;20..20;true
condition;false;;bool;some(items, #.price > 1 and |;closure;object;;;28..28;true
condition;false;;bool;some(items, (#.price > 1 and |;closure;object;;;29..29;true
condition;false;;bool;some(items as x, |;closure;object;;;17..17;true
condition;false;;bool;some(items as x, x|;closure;object;;;17..18;false
condition;false;;bool;some(statuses as s, |;closure;status;open=Open case,closed=Closed;;20..20;true
condition;false;;bool;some(statuses as s, s == |;value;status;open=Open case,closed=Closed;;25..25;true
condition;false;;bool;some(items as x, some(statuses as s, |;closure;status;open=Open case,closed=Closed;;37..37;true
condition;false;;bool;len(name |;operator;;;==,!=,in,not in;9..9;true
condition;false;;bool;len(age |;operator;;;==,!=,<,<=,>,>=,in,not in;8..8;true
# Unary cells
unary;true;$status;;|;unaryStart;status;open=Open case,closed=Closed;!=,in,not in;0..0;true
unary;true;$status;;"|;inString;status;open=Open case,closed=Closed;!=,in,not in;0..1;true
unary;true;$status;;"op|;inString;status;open=Open case;!=,in,not in;0..3;true
unary;true;$status;;"|;inString;status;open=Open case,closed=Closed;;0..1;true
unary;true;$status;;"op|;inString;status;open=Open case;;0..3;true
unary;true;$status;;"open"|;logical;bool;;and,or;6..6;false
unary;true;$status;;"open" |;logical;bool;;and,or;7..7;true
unary;true;$status;;"open", |;unaryStart;status;closed=Closed;!=,in,not in;8..8;true
unary;true;$status;;"open", "|;inString;status;closed=Closed;!=,in,not in;8..9;true
unary;true;$status;;"open", "|;inString;status;closed=Closed;;8..9;true
unary;true;$status;;"open" or |;unaryStart;status;closed=Closed;!=,in,not in;10..10;true
unary;true;$status;;"open" and |;unaryStart;status;closed=Closed;!=,in,not in;11..11;true
unary;true;$status;;"open", "closed" |;logical;bool;;and,or;17..17;true
@@ -173,7 +178,7 @@ condition;false;;bool;Ünïcode == "|;start;bool;;;14..14;true
# Adversarial: unary cells
unary;true;$number;;>= 5 and < |;value;number;;;11..11;true
unary;true;$number;;$ > 5 and $ < |;value;number;;;14..14;true
unary;true;$number;;>|;value;number;;;1..1;true
unary;true;$number;;>|;operator;;;>,>=;0..1;true
unary;true;$number;; > |;value;number;;;4..4;true
unary;true;$number;;> 5 and|;logical;bool;;and,or;4..7;true
unary;true;$number;;> 5 |);logical;bool;;and,or;4..4;true
@@ -188,7 +193,7 @@ unary;true;$status;;not |;start;bool;;;4..4;true
unary;true;$status;;in ["open", "cl|;inString;status;closed=Closed;;12..15;true
unary;true;$status;;== |;value;status;open=Open case,closed=Closed;;3..3;true
unary;true;$status;;!= "|;inString;status;open=Open case,closed=Closed;;3..4;true
unary;true;$status;;"open" or "|;inString;status;closed=Closed;!=,in,not in;10..11;true
unary;true;$status;;"open" or "|;inString;status;closed=Closed;;10..11;true
unary;true;$status;;"open" or "closed" or |;unaryStart;status;;!=,in,not in;22..22;true
unary;true;$string;;startsWith($, "|;inString;string;;;14..15;false
unary;true;$string;;contains($, "|;inString;any;;;12..13;false
@@ -196,7 +201,7 @@ unary;true;$string;;len($) > |;value;number;;;9..9;true
unary;true;$string;;upper($) == "|;inString;string;;;12..13;true
unary;true;$date;;d($) > d("|;inString;date;;;9..10;true
unary;true;$date;;> d("2024-01-01").add(1, "|;inString;DurationUnit;seconds,second,secs,sec,s,minutes,minute,min,mins,m,hours,hour,hr,hrs,h,days,day,d,weeks,week,w,months,month,mo,M,quarters,quarter,qtr,q,years,year,y;;25..26;false
unary;true;$date;;> d().|;member;;;;6..6;false
unary;true;$date;;> d().|;member;date;;;6..6;false
unary;true;$date;;> d().startOf("|;inString;DurationUnit;seconds,second,secs,sec,s,minutes,minute,min,mins,m,hours,hour,hr,hrs,h,days,day,d,weeks,week,w,months,month,mo,M,quarters,quarter,qtr,q,years,year,y;;14..15;false
unary;true;{"Object":{"$":{"Array":{"Enum":["status",["open","closed"]]}}}};;some($, # == "|;inString;status;open=Open case,closed=Closed;;13..14;true
unary;true;{"Object":{"$":{"Array":{"Enum":["status",["open","closed"]]}}}};;some($, |;closure;status;open=Open case,closed=Closed;;8..8;true
@@ -273,14 +278,14 @@ condition;false;;bool;status == 'op|;inString;status;open=Open case;;10..13;true
condition;false;;bool;customer.status == "open" and customer.age > 18 and customer.name == "x" and since > d() and tier == "|;inString;"gold" | "silver";gold,silver;;101..102;true
condition;false;;bool;inbound > |;value;number;;;10..10;true
condition;false;;bool;andrew == "|;inString;string;;;10..11;true
condition;false;;bool;nothing |;logical;bool;;and,or;8..8;true
condition;false;;bool;nothing |;logical;bool;;and,or,==,!=;8..8;true
condition;false;;bool;true_value == |;value;number;;;14..14;true
condition;false;;bool;in_progress |;logical;bool;;and,or;12..12;true
condition;false;;bool;in_progress |;logical;bool;;and,or,==,!=;12..12;true
condition;false;;bool;customer.in|;member;;;;9..11;false
condition;false;;bool;in|;start;bool;;;0..2;false
condition;false;;bool;not|;start;bool;;;0..3;false
condition;false;;bool;age > 1 and or |;start;bool;;;15..15;true
condition;false;;bool;status == "open" "|;inString;bool;;and,or;17..18;true
condition;false;;bool;status == "open" "|;inString;bool;;;17..18;true
condition;false;;bool;$root.status == "|;inString;status;open=Open case,closed=Closed;;16..17;true
condition;false;;bool;age > 1 and $root.|;member;;;;18..18;false
# Adversarial: second batch
@@ -325,8 +330,8 @@ unary;true;$bool;;true or |;unaryStart;bool;;==,!=;8..8;true
unary;true;$bool;;not |;start;bool;;;4..4;true
unary;true;$any;;"x" |;logical;bool;;and,or;4..4;true
unary;true;$any;;$.|;member;;;;2..2;false
unary;true;{"Object":{"$":{"Nullable":{"Enum":["status",["open","closed"]]}}}};;|;unaryStart;status?;open=Open case,closed=Closed;!=,in,not in,==;0..0;true
unary;true;{"Object":{"$":{"Nullable":{"Enum":["status",["open","closed"]]}}}};;"open", |;unaryStart;status?;closed=Closed;!=,in,not in,==;8..8;true
unary;true;{"Object":{"$":{"Nullable":{"Enum":["status",["open","closed"]]}}}};;|;unaryStart;status?;open=Open case,closed=Closed,null;==,!=,in,not in;0..0;true
unary;true;{"Object":{"$":{"Nullable":{"Enum":["status",["open","closed"]]}}}};;"open", |;unaryStart;status?;closed=Closed,null;==,!=,in,not in;8..8;true
unary;true;{"Object":{"$":{"Enum":[null,["a","b"]]}}};;|;unaryStart;"a" | "b";a,b;!=,in,not in;0..0;true
unary;true;{"Object":{"$":{"Array":"String"}}};;|;unaryStart;string[];;in,not in;0..0;true
unary;true;{"Object":{"$":{"Array":"String"}}};;contains($, "|;inString;string;;;12..13;false
@@ -408,7 +413,7 @@ value;false;;status;age > 18 ? "op|;inString;status;open=Open case;;11..14;true
value;false;;status;active ? "open" : (age > 1 ? "closed" : "|;inString;status;open=Open case,closed=Closed;;40..41;true
value;false;;status;age > 18 ? "open" : age > 10 ? "|;inString;status;open=Open case,closed=Closed;;31..32;true
value;false;;status;customer.status ?? |;value;status;open=Open case,closed=Closed;;19..19;true
value;false;;status;customer.|;member;;;;9..9;false
value;false;;status;customer.|;member;status;;;9..9;false
value;false;;status;status == "open" ? |;value;status;open=Open case,closed=Closed;;19..19;true
value;false;;status;status == "|;inString;status;open=Open case,closed=Closed;;10..11;true
value;false;;status;customer.status == "open" |;logical;bool;;and,or;26..26;true
@@ -472,9 +477,9 @@ condition;false;;bool;some(items, #.price > |1);value;number;;;22..22;true
condition;false;;bool;age in [1..|5];range;number;;;11..11;false
condition;false;;bool;age in [|1..5];range;number;;;8..8;false
unary;true;$number;;> 5 and |< 10;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;8..8;true
unary;true;$number;;>| 5;value;number;;;1..1;true
unary;true;$status;;"op|en", "closed";inString;status;open=Open case;!=,in,not in;0..6;true
unary;true;$status;;"open", "cl|osed";inString;status;closed=Closed;!=,in,not in;8..16;true
unary;true;$number;;>| 5;operator;;;>,>=;0..1;true
unary;true;$status;;"op|en", "closed";inString;status;open=Open case;;0..6;true
unary;true;$status;;"open", "cl|osed";inString;status;closed=Closed;;8..16;true
unary;true;$status;;|"open";unaryStart;status;closed=Closed;!=,in,not in;0..0;true
# Adversarial: nasty input, second batch
condition;false;;bool;)|;start;bool;;;1..1;true
@@ -483,7 +488,7 @@ condition;false;;bool;age > 1) and status == "|;inString;status;open=Open case,c
condition;false;;bool;((age > 1 |;logical;bool;;and,or;10..10;true
condition;false;;bool;status == == "|;inString;;;;13..14;true
condition;false;;bool;status == "open" == "|;inString;string;;;20..21;true
condition;false;;bool;status "|;inString;;;==,!=,in,not in;7..8;false
condition;false;;bool;status "|;inString;;;;7..8;false
condition;false;;bool;status "open" |;operator;;;==,!=,<,<=,>,>=,in,not in;14..14;true
condition;false;;bool;age > > |;value;;;;8..8;true
condition;false;;bool;and |;start;bool;;;4..4;true
@@ -519,22 +524,22 @@ condition;false;;bool;status == "open" and status == "|" and age > 1;inString;st
# Adversarial: third batch
unary;true;$status;; |;unaryStart;status;open=Open case,closed=Closed;!=,in,not in;3..3;true
unary;true;$status;;$ != "|;inString;status;open=Open case,closed=Closed;;5..6;true
unary;true;$status;;"open" and "|;inString;status;closed=Closed;!=,in,not in;11..12;true
unary;true;$status;;"open" and "|;inString;status;closed=Closed;;11..12;true
unary;true;$status;;== "open" or == "|;inString;status;open=Open case,closed=Closed;;16..17;true
unary;true;$status;;$root.customer.status == "|;inString;status;open=Open case,closed=Closed;;25..26;true
unary;true;$status;;customer.|;member;;;;9..9;false
unary;true;$status;;customer.|;member;status;;;9..9;false
unary;true;$number;;< |;value;number;;;2..2;true
unary;true;$number;;!= |;value;number;;;3..3;true
unary;true;$number;;1.|;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;0..2;true
unary;true;$number;;> 1.5 |;logical;bool;;and,or;6..6;true
unary;true;$number;;> len(|;argument;any;;;6..6;true
unary;true;$number;;> customer.|;member;;;;11..11;false
unary;true;$number;;> customer.|;member;number;;;11..11;false
unary;true;$number;;== customer.age |;logical;bool;;and,or;16..16;true
unary;true;$number;;[1..5] or [|;listElement;number;;;11..11;true
unary;true;$number;;[1, 2] or (|;range;number;;;11..11;false
unary;true;$number;;> 5 and < 10 and |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;17..17;true
unary;true;$number;;[1..5]|;logical;bool;;and,or;6..6;false
unary;true;$string;;"a" or "b" or "|;inString;string;;!=,in,not in;14..15;true
unary;true;$string;;"a" or "b" or "|;inString;string;;;14..15;true
unary;true;$string;;startsWith($, "a") or |;unaryStart;string;;!=,in,not in;22..22;true
unary;true;$string;;!= "|;inString;string;;;3..4;true
unary;true;$string;;"a"|;logical;bool;;and,or;3..3;false
@@ -653,6 +658,9 @@ condition;false;{"Object":{"something":{"Nullable":{"Const":"hello"}}}};bool;som
condition;false;{"Object":{"something":{"Nullable":{"Const":"hello"}}}};bool;something == "|;inString;"hello"?;hello;;13..14;true
condition;false;{"Object":{"something":{"Nullable":{"Const":"hello"}}}};bool;something |;operator;;;==,!=,in,not in,??;10..10;true
condition;false;{"Object":{"something":{"Enum":[null,["a","b"]]}}};bool;something == |;value;"a" | "b";a,b;;13..13;true
value;false;{"Object":{"g":{"Enum":[null,["hello","world"]]}}};;g == |;value;"hello" | "world";hello,world;;5..5;true
value;false;{"Object":{"g":{"Enum":[null,["hello","world"]]}}};;g == "|;inString;"hello" | "world";hello,world;;5..6;true
value;false;{"Object":{"g":{"Enum":[null,["hello","world"]]}}};;g != |;value;"hello" | "world";hello,world;;5..5;true
condition;false;{"Object":{"something":{"Const":"hello"}}};bool;"open" == |;value;string;;;10..10;true
condition;false;{"Object":{"something":{"Const":"hello"}}};bool;"hello" == |;value;string;;;11..11;true
value;false;{"Object":{"something":{"Const":"hello"}}};{"Const":"hello"};|;value;"hello";hello;;0..0;true
@@ -673,3 +681,34 @@ condition;false;;bool;amount > |;value;number?;;;9..9;true
condition;false;;bool;amount == |;value;number?;null;;10..10;true
value;false;{"Object":{"since":{"Nullable":"Date"}}};date;since ?? |;value;date;;;9..9;true
condition;false;{"Object":{"since":{"Nullable":"Date"}}};bool;since |;operator;;;==,!=,<,<=,>,>=,in,not in,??;6..6;true
unary;true;$number;;<|;operator;;;<,<=;0..1;true
unary;true;$number;;>=|;value;number;;;2..2;true
condition;false;;bool;age >|;operator;;;>,>=;4..5;true
condition;false;;bool;age ==|;value;number;;;6..6;true
unary;true;$number;;=|;operator;;;==;0..1;true
unary;true;$number;;!|;operator;;;!=;0..1;true
condition;false;;bool;claim.amount =|;operator;;;==;13..14;true
condition;false;;bool;name !|;operator;;;!=;5..6;true
# Glued operator prefixes: the typed operator stays replaceable while longer ones begin with it
condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status ?|;operator;;;??;7..8;true
condition;false;;bool;age > 1 ?|;start;bool;;;9..9;true
unary;true;$number;;>= 1 and >|;operator;;;>,>=;9..10;true
unary;true;$number;;>= 1 and >| 2;operator;;;>,>=;9..10;true
unary;true;$string;;>|;value;string;;;1..1;true
unary;true;$bool;;=|;operator;;;==;0..1;true
unary;true;$bool;;!|;operator;;;!=;0..1;true
condition;false;;bool;age <|;operator;;;<,<=;4..5;true
condition;false;;bool;age <=|;value;number;;;6..6;true
condition;false;;bool;age ==|;value;number;;;6..6;true
condition;false;;bool;age !=|;value;number;;;6..6;true
condition;false;;bool;age >|;operator;;;>,>=;4..5;true
condition;false;;bool;age >=|;value;number;;;6..6;true
unary;true;$status;;=|;operator;;;==;0..1;true
unary;true;$string;;=|;operator;;;==;0..1;true
# Bare bool fields also compare; literals, calls, groups and negations only connect
condition;false;{"Object":{"aircraft":{"Object":{"apuFault":"Bool"}}}};bool;aircraft.apuFault |;logical;bool;;and,or,==,!=;18..18;true
condition;false;{"Object":{"aircraft":{"Object":{"apuFault":"Bool"}}}};bool;aircraft.apuFault == |;value;bool;;;21..21;true
condition;false;{"Object":{"aircraft":{"Object":{"apuFault":"Bool"}}}};bool;(aircraft.apuFault) |;logical;bool;;and,or;20..20;true
condition;false;{"Object":{"aircraft":{"Object":{"apuFault":"Bool"}}}};bool;not aircraft.apuFault |;logical;bool;;and,or;22..22;true
Can't render this file because it contains an unexpected character in line 20 and column 44.
+65
View File
@@ -418,3 +418,68 @@ fn assignments_and_semicolons() {
let slot = slot_at("x = 1; x |", false, SlotRole::Condition, "", "bool");
assert_eq!(slot.state, SlotState::Operator);
}
#[test]
fn closure_locals_follow_the_caret() {
let names = |slot: &zen_expression::slot::Slot| -> Vec<String> {
slot.locals.iter().map(|l| l.name.clone()).collect()
};
let element = |slot: &zen_expression::slot::Slot, i: usize| slot.locals[i].kind.to_string();
let slot = slot_at("map(items as x, |", false, SlotRole::Value, "", "");
assert_eq!(slot.state, SlotState::Closure);
assert_eq!(names(&slot), ["x"]);
assert!(matches!(slot.locals[0].kind, VariableType::Object(_)));
assert!(matches!(slot.operand, Some(VariableType::Object(_))));
let slot = slot_at("map(items as x, x|", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["x"]);
assert_eq!(slot.replace_span, (16, 17));
let nested =
r#"{"Object":{"m":{"Array":{"Object":{"a":"Number","tags":{"Array":"String"}}}}}}"#;
let slot = slot_at(
"map(m as x, map(x.tags as y, |",
false,
SlotRole::Value,
nested,
"",
);
assert_eq!(names(&slot), ["y", "x"]);
assert_eq!(element(&slot, 0), "string");
assert!(matches!(slot.locals[1].kind, VariableType::Object(_)));
let slot = slot_at("map(m, map(#.tags, |", false, SlotRole::Value, nested, "");
assert_eq!(names(&slot), ["#"]);
assert_eq!(element(&slot, 0), "string");
let slot = slot_at("map(items, |", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["#"]);
assert!(matches!(slot.locals[0].kind, VariableType::Object(_)));
let slot = slot_at(
"filter(items as x, x.price > |",
false,
SlotRole::Condition,
"",
"bool",
);
assert_eq!(slot.state, SlotState::Value);
assert_eq!(names(&slot), ["x"]);
let slot = slot_at("map(items as x, len(x|", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["x"]);
assert!(slot_at(
"map(items as x, x.price) + |",
false,
SlotRole::Value,
"",
""
)
.locals
.is_empty());
assert!(slot_at("map(|", false, SlotRole::Value, "", "")
.locals
.is_empty());
}
+24
View File
@@ -172,3 +172,27 @@ fn failure_tests() {
);
}
}
#[test]
fn joiners_nest_inside_brackets_arguments_and_templates() {
let sources = [
"(a > 1 and b > 2)",
"== (a > 1 or b > 2)",
"len([a > 1 and b]) > 0",
"contains(x, (a and b))",
"`${(a > 30 and d() > d(\"2026-09-19\")) ? true : false}`",
"== `${a > 1 or b > 2}`",
"[1, 2] and (a > 1 and b)",
];
let mut lexer = Lexer::new();
let mut bump = Bump::new();
for src in sources {
bump.reset();
let tokens = lexer.tokenize(&bump, src).unwrap();
let result = Parser::try_new(&tokens, &bump).unwrap().unary().parse();
assert!(result.error().is_ok(), "Parser failed for: {src}");
assert!(result.is_complete, "Parser stopped early for: {src}");
}
}
@@ -0,0 +1,85 @@
use std::cell::RefCell;
use std::rc::Rc;
use zen_expression::variable::{VariableMapExt, VariableType};
type Fields = ahash::HashMap<Rc<str>, VariableType>;
fn cyclic_pair() -> (VariableType, VariableType) {
let claim = VariableType::Object(Rc::new(RefCell::new(Fields::new())));
let address = VariableType::Object(Rc::new(RefCell::new(Fields::new())));
let VariableType::Object(claim_fields) = &claim else {
unreachable!()
};
let VariableType::Object(address_fields) = &address else {
unreachable!()
};
claim_fields
.borrow_mut()
.insert(Rc::from("amount"), VariableType::Number);
claim_fields
.borrow_mut()
.insert(Rc::from("address"), address.shallow_clone());
claim_fields
.borrow_mut()
.insert(Rc::from("items"), address.shallow_clone().array());
address_fields
.borrow_mut()
.insert(Rc::from("city"), VariableType::String);
address_fields
.borrow_mut()
.insert(Rc::from("claim"), claim.shallow_clone());
(claim, address)
}
#[test]
fn serialize_cuts_cycles_and_keeps_the_derived_format() {
let (claim, _) = cyclic_pair();
let json = serde_json::to_value(&claim).expect("cyclic type serialises");
assert_eq!(json["Object"]["amount"], "Number");
assert_eq!(json["Object"]["address"]["Object"]["city"], "String");
assert_eq!(
json["Object"]["address"]["Object"]["claim"],
serde_json::json!({ "Object": {} })
);
assert_eq!(
json["Object"]["items"]["Array"]["Object"]["claim"],
serde_json::json!({ "Object": {} })
);
}
#[test]
fn serialize_round_trips_acyclic_types() {
let mut fields = Fields::new();
fields.insert(
Rc::from("a"),
VariableType::Nullable(Rc::new(VariableType::Enum(
Some(Rc::from("status")),
vec![Rc::from("open"), Rc::from("closed")],
)))
.array(),
);
fields.insert(Rc::from("c"), VariableType::Const(Rc::from("v")));
fields.insert(Rc::from("d"), VariableType::Date);
let original = VariableType::Object(Rc::new(RefCell::new(fields)));
let json = serde_json::to_string(&original).expect("serialise");
let back: VariableType = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, original);
}
#[test]
fn merge_and_satisfies_terminate_on_cyclic_objects() {
let (claim, address) = cyclic_pair();
let merged = claim.merge(&claim);
assert!(matches!(merged, VariableType::Object(_)));
assert!(claim.satisfies(&claim));
assert!(claim.get("address").satisfies(&address));
let (other_claim, _) = cyclic_pair();
let merged = claim.merge(&other_claim);
assert!(matches!(merged, VariableType::Object(_)));
assert!(claim.satisfies(&other_claim));
assert!(claim
.merge(&VariableType::Number)
.satisfies(&VariableType::Any));
}
+105 -2
View File
@@ -3,7 +3,8 @@ mod util;
use ahash::HashMap;
pub use ahash::HashMapExt as VariableMapExt;
use serde::{Deserialize, Serialize};
use serde::ser::{SerializeMap, SerializeTupleVariant};
use serde::{Deserialize, Serialize, Serializer};
use std::cell::RefCell;
use std::fmt::{Display, Write};
use std::hash::{Hash, Hasher};
@@ -11,7 +12,10 @@ use std::rc::Rc;
type RcCell<T> = Rc<RefCell<T>>;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
/// Object nesting kept when serialising; deeper (or cyclic) objects are emitted empty.
pub const MAX_TYPE_DEPTH: usize = 32;
#[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
pub enum VariableType {
Any,
Null,
@@ -34,6 +38,105 @@ impl VariableType {
}
}
const TYPE_NAME: &str = "VariableType";
struct Guarded<'a> {
inner: &'a VariableType,
path: &'a [*const ()],
}
struct GuardedFields<'a> {
fields: &'a HashMap<Rc<str>, VariableType>,
path: Vec<*const ()>,
}
impl Serialize for VariableType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Guarded {
inner: self,
path: &[],
}
.serialize(serializer)
}
}
impl Serialize for Guarded<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.inner {
VariableType::Any => serializer.serialize_unit_variant(TYPE_NAME, 0, "Any"),
VariableType::Null => serializer.serialize_unit_variant(TYPE_NAME, 1, "Null"),
VariableType::Bool => serializer.serialize_unit_variant(TYPE_NAME, 2, "Bool"),
VariableType::String => serializer.serialize_unit_variant(TYPE_NAME, 3, "String"),
VariableType::Number => serializer.serialize_unit_variant(TYPE_NAME, 4, "Number"),
VariableType::Date => serializer.serialize_unit_variant(TYPE_NAME, 5, "Date"),
VariableType::Interval => serializer.serialize_unit_variant(TYPE_NAME, 6, "Interval"),
VariableType::Array(inner) => serializer.serialize_newtype_variant(
TYPE_NAME,
7,
"Array",
&Guarded {
inner,
path: self.path,
},
),
VariableType::Object(obj) => {
let ptr = Rc::as_ptr(obj) as *const ();
let cut = self.path.len() >= MAX_TYPE_DEPTH || self.path.contains(&ptr);
let empty = HashMap::default();
let borrowed;
let fields: &HashMap<Rc<str>, VariableType> = if cut {
&empty
} else {
borrowed = obj.borrow();
&borrowed
};
let mut path = self.path.to_vec();
path.push(ptr);
serializer.serialize_newtype_variant(
TYPE_NAME,
8,
"Object",
&GuardedFields { fields, path },
)
}
VariableType::Const(c) => {
serializer.serialize_newtype_variant(TYPE_NAME, 9, "Const", c.as_ref())
}
VariableType::Enum(name, values) => {
let mut tv = serializer.serialize_tuple_variant(TYPE_NAME, 10, "Enum", 2)?;
tv.serialize_field(name)?;
tv.serialize_field(values)?;
tv.end()
}
VariableType::Nullable(inner) => serializer.serialize_newtype_variant(
TYPE_NAME,
11,
"Nullable",
&Guarded {
inner,
path: self.path,
},
),
}
}
}
impl Serialize for GuardedFields<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.fields.len()))?;
for (key, value) in self.fields.iter() {
map.serialize_entry(
key.as_ref(),
&Guarded {
inner: value,
path: &self.path,
},
)?;
}
map.end()
}
}
impl Default for VariableType {
fn default() -> Self {
VariableType::Null
+27 -7
View File
@@ -1,4 +1,4 @@
use crate::variable_type::VariableType;
use crate::variable_type::{MAX_TYPE_DEPTH, VariableType};
use ahash::{HashMap, HashMapExt};
use rust_decimal::prelude::Zero;
use std::cell::RefCell;
@@ -42,11 +42,18 @@ impl VariableType {
}
pub fn satisfies(&self, constraint: &Self) -> bool {
self.satisfies_at(constraint, 0)
}
fn satisfies_at(&self, constraint: &Self, depth: usize) -> bool {
if depth > MAX_TYPE_DEPTH {
return true;
}
match (self, constraint) {
(VariableType::Any, _) | (_, VariableType::Any) => true,
(VariableType::Nullable(a), VariableType::Nullable(b)) => a.satisfies(b),
(VariableType::Nullable(a), VariableType::Nullable(b)) => a.satisfies_at(b, depth),
(VariableType::Nullable(_), _) => false,
(other, VariableType::Nullable(inner)) => other.satisfies(inner),
(other, VariableType::Nullable(inner)) => other.satisfies_at(inner, depth),
(VariableType::Null, VariableType::Null) => true,
(VariableType::Bool, VariableType::Bool) => true,
@@ -56,13 +63,16 @@ impl VariableType {
(VariableType::Number, VariableType::Date) => true,
(_, VariableType::Date) if self.widen().is_string() => true,
(VariableType::Interval, VariableType::Interval) => true,
(VariableType::Array(a1), VariableType::Array(a2)) => a1.satisfies(a2),
(VariableType::Array(a1), VariableType::Array(a2)) => a1.satisfies_at(a2, depth + 1),
(VariableType::Object(o1), VariableType::Object(o2)) => {
if Rc::ptr_eq(o1, o2) {
return true;
}
let o1 = o1.borrow();
let o2 = o2.borrow();
o2.iter().all(|(k, v)| match o1.get(k) {
Some(tv) => tv.satisfies(v),
Some(tv) => tv.satisfies_at(v, depth + 1),
None => matches!(
v,
VariableType::Any | VariableType::Null | VariableType::Nullable(_)
@@ -141,6 +151,13 @@ impl VariableType {
}
pub fn merge(&self, other: &Self) -> Self {
self.merge_at(other, 0)
}
fn merge_at(&self, other: &Self, depth: usize) -> Self {
if depth > MAX_TYPE_DEPTH {
return VariableType::Any;
}
let (left, left_nullable) = self.unwrap_nullable();
let (right, right_nullable) = other.unwrap_nullable();
let nullable = left_nullable || right_nullable;
@@ -164,10 +181,13 @@ impl VariableType {
(VariableType::Any, other) | (other, VariableType::Any) => {
VariableType::Array(Rc::new(other.clone()))
}
(l, r) => VariableType::Array(Rc::new(l.merge(r))),
(l, r) => VariableType::Array(Rc::new(l.merge_at(r, depth + 1))),
}
}
}
(VariableType::Object(o1), VariableType::Object(o2)) if Rc::ptr_eq(o1, o2) => {
VariableType::Object(o1.clone())
}
(VariableType::Object(o1), VariableType::Object(o2)) => {
let o1 = o1.borrow();
let o2 = o2.borrow();
@@ -181,7 +201,7 @@ impl VariableType {
match merged.entry(k.clone()) {
Entry::Occupied(mut entry) => {
let current = entry.get();
entry.insert(current.merge(v));
entry.insert(current.merge_at(v, depth + 1));
}
Entry::Vacant(entry) => {
entry.insert(v.clone());