diff --git a/bindings/nodejs/dts-header.d.ts b/bindings/nodejs/dts-header.d.ts index 3c0a8df4..b989b588 100644 --- a/bindings/nodejs/dts-header.d.ts +++ b/bindings/nodejs/dts-header.d.ts @@ -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 = | { diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index 3928f001..9d2e49b0 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -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 { diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs index 3a97a221..7b96e26c 100644 --- a/bindings/nodejs/src/policy.rs +++ b/bindings/nodejs/src/policy.rs @@ -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, } #[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 = 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()) } diff --git a/bindings/nodejs/src/slot.rs b/bindings/nodejs/src/slot.rs index ae32da78..6ee0279c 100644 --- a/bindings/nodejs/src/slot.rs +++ b/bindings/nodejs/src/slot.rs @@ -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 { 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) } diff --git a/core/engine/src/policy/blocks/context.rs b/core/engine/src/policy/blocks/context.rs index fa74f8e4..cca2893f 100644 --- a/core/engine/src/policy/blocks/context.rs +++ b/core/engine/src/policy/blocks/context.rs @@ -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, VariableType>>; pub type SharedPoisonedPaths = Rc>>>; +pub type SharedDeclaredPaths = Rc; 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, } impl AnalysisContext { + #[allow(clippy::too_many_arguments)] pub fn new( scope: VariableType, policy_path: Arc, @@ -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 { diff --git a/core/engine/src/policy/blocks/mod.rs b/core/engine/src/policy/blocks/mod.rs index dd571243..b3bd7cf6 100644 --- a/core/engine/src/policy/blocks/mod.rs +++ b/core/engine/src/policy/blocks/mod.rs @@ -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}; diff --git a/core/engine/src/policy/ir.rs b/core/engine/src/policy/ir.rs index 223dc88d..2b90de0f 100644 --- a/core/engine/src/policy/ir.rs +++ b/core/engine/src/policy/ir.rs @@ -221,7 +221,8 @@ impl DataModelIr { visited: &mut HashSet>, ) -> 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, diff --git a/core/engine/src/policy/queries/dependency.rs b/core/engine/src/policy/queries/dependency.rs index 63c0bf99..5fdcba3b 100644 --- a/core/engine/src/policy/queries/dependency.rs +++ b/core/engine/src/policy/queries/dependency.rs @@ -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, pub diagnostics: Vec, + base_fields: HashMap, VariableType>, + write_log: Vec<(PropertyPath, VariableType)>, + log_start: HashMap, + block_scopes: RefCell>, +} + +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, pub instance_source: Option, + /// 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, @@ -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 { 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 { - 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) -> Option> { + type Ready = BinaryHeap>; + let policy_of = |idx: NodeIndex| { + graph[idx] + .written_by + .as_ref() + .map(|b| b.policy_path.clone()) + }; + let mut pending: HashMap = HashMap::new(); + let mut ready: HashMap>, Ready> = HashMap::new(); + let enqueue = |ready: &mut HashMap>, 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>, Ready>, key: &Option>| { + ready.get(key).is_some_and(|heap| !heap.is_empty()) + }; + let mut current: Option> = None; + let mut order = Vec::with_capacity(graph.node_count()); + loop { + let key = if has_ready(&ready, &None) { + None + } else if has_ready(&ready, ¤t) { + 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>, 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 = HashMap::new(); let mut per_rule: Vec = Vec::new(); let mut diagnostics: Vec = 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, optional: HashSet, diff --git a/core/engine/src/workspace/db.rs b/core/engine/src/workspace/db.rs index 6c7de5d9..d685b0ad 100644 --- a/core/engine/src/workspace/db.rs +++ b/core/engine/src/workspace/db.rs @@ -538,6 +538,7 @@ impl Db { &unit.members, &self.intellisense, Rc::new(unit.dictionary_types()), + Rc::new(unit.data_model_paths.clone()), )) }) .clone() diff --git a/core/engine/src/workspace/editor.rs b/core/engine/src/workspace/editor.rs index 618840d9..032e0146 100644 --- a/core/engine/src/workspace/editor.rs +++ b/core/engine/src/workspace/editor.rs @@ -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> { + 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 } diff --git a/core/engine/src/workspace/graph/cursor.rs b/core/engine/src/workspace/graph/cursor.rs index 1965ff2c..4c6d0470 100644 --- a/core/engine/src/workspace/graph/cursor.rs +++ b/core/engine/src/workspace/graph/cursor.rs @@ -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 diff --git a/core/engine/src/workspace/slot/graph.rs b/core/engine/src/workspace/slot/graph.rs index b67b478f..d85a3b55 100644 --- a/core/engine/src/workspace/slot/graph.rs +++ b/core/engine/src/workspace/slot/graph.rs @@ -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 { 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, + col: &Arc, + scope: &VariableType, +) -> Option { + let mut merged: Option = 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 { let schema = content.nodes.iter().find_map(|node| match &node.kind { DecisionNodeKind::OutputNode { content } => content.schema.as_ref(), diff --git a/core/engine/src/workspace/slot/policy.rs b/core/engine/src/workspace/slot/policy.rs index 0c4aaa48..d1274dde 100644 --- a/core/engine/src/workspace/slot/policy.rs +++ b/core/engine/src/workspace/slot/policy.rs @@ -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 { - 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 { 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, + col: &Arc, + scope: &VariableType, +) -> Option { + let mut merged: Option = 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, diff --git a/core/engine/tests/policy.rs b/core/engine/tests/policy.rs index d22203fb..2ac3d756 100644 --- a/core/engine/tests/policy.rs +++ b/core/engine/tests/policy.rs @@ -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 = 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:?}" + ); + } +} diff --git a/core/engine/tests/policy_slot.rs b/core/engine/tests/policy_slot.rs index c92504c9..7033b86f 100644 --- a/core/engine/tests/policy_slot.rs +++ b/core/engine/tests/policy_slot.rs @@ -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 = 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(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 = 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 = 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 { + 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 = 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 { + 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 { + 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(".")); +} diff --git a/core/expression/src/intellisense/completion.rs b/core/expression/src/intellisense/completion.rs index 9fd68bb3..f07fa746 100644 --- a/core/expression/src/intellisense/completion.rs +++ b/core/expression/src/intellisense/completion.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] pub method_for: Option, + /// Type of the field or local a variable or property completion names. + #[serde(skip)] + pub var_type: Option, + /// 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, VariableType)], ) -> Vec { 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 { + /// `locals` are closure-bound names (innermost first); they lead the list above the fields. + pub fn build_scope(data: &VariableType, locals: &[(Rc, VariableType)]) -> Vec { 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, VariableType>) -> Vec<(&Rc, &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) -> 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, } } diff --git a/core/expression/src/intellisense/mod.rs b/core/expression/src/intellisense/mod.rs index 55a032f9..c398c5ef 100644 --- a/core/expression/src/intellisense/mod.rs +++ b/core/expression/src/intellisense/mod.rs @@ -92,12 +92,13 @@ impl IntelliSense { pos: u32, data: &VariableType, ) -> Vec { + 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( diff --git a/core/expression/src/parser/parser.rs b/core/expression/src/parser/parser.rs index dc2a8211..4447938f 100644 --- a/core/expression/src/parser/parser.rs +++ b/core/expression/src/parser/parser.rs @@ -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, } diff --git a/core/expression/src/parser/unary.rs b/core/expression/src/parser/unary.rs index b1b7b642..37d1c3f6 100644 --- a/core/expression/src/parser/unary.rs +++ b/core/expression/src/parser/unary.rs @@ -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; }; diff --git a/core/expression/src/slot/classify.rs b/core/expression/src/slot/classify.rs index eab4e4d9..bfd2573c 100644 --- a/core/expression/src/slot/classify.rs +++ b/core/expression/src/slot/classify.rs @@ -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, 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 { + 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, VariableType)> { + let frames = self.frames(limit); + let mut out: Vec<(Rc, 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 = 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 { + 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 { 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 { + 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 { - 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; diff --git a/core/expression/src/slot/mod.rs b/core/expression/src/slot/mod.rs index 0bb6653b..27402f41 100644 --- a/core/expression/src/slot/mod.rs +++ b/core/expression/src/slot/mod.rs @@ -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, pub listed: Vec, pub auto_open: bool, + pub locals: Vec, } 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, 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 { + 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), + } +} diff --git a/core/expression/src/slot/operators.rs b/core/expression/src/slot/operators.rs index b761b447..e6cfb1ed 100644 --- a/core/expression/src/slot/operators.rs +++ b/core/expression/src/slot/operators.rs @@ -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; diff --git a/core/expression/src/slot/tests.rs b/core/expression/src/slot/tests.rs index af59f193..5b97f2a4 100644 --- a/core/expression/src/slot/tests.rs +++ b/core/expression/src/slot/tests.rs @@ -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), diff --git a/core/expression/tests/analysis.rs b/core/expression/tests/analysis.rs index e458dab8..0e48bf4e 100644 --- a/core/expression/tests/analysis.rs +++ b/core/expression/tests/analysis.rs @@ -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 = is + .completions("", 0, &data) + .into_iter() + .map(|c| c.label) + .collect(); + let second: Vec = 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 = 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::>(), + ["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::>(), + ["#"] + ); + + 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::>(), + ["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)); +} diff --git a/core/expression/tests/data/analysis/enums.toml b/core/expression/tests/data/analysis/enums.toml index ab2a2e00..87cbc7fd 100644 --- a/core/expression/tests/data/analysis/enums.toml +++ b/core/expression/tests/data/analysis/enums.toml @@ -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'" diff --git a/core/expression/tests/data/analysis/nullable.toml b/core/expression/tests/data/analysis/nullable.toml index deb1e261..e80d10ee 100644 --- a/core/expression/tests/data/analysis/nullable.toml +++ b/core/expression/tests/data/analysis/nullable.toml @@ -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'" diff --git a/core/expression/tests/data/slots.csv b/core/expression/tests/data/slots.csv index 5987f765..856bfa75 100644 --- a/core/expression/tests/data/slots.csv +++ b/core/expression/tests/data/slots.csv @@ -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 diff --git a/core/expression/tests/slots.rs b/core/expression/tests/slots.rs index 33fc50b5..2cb00bef 100644 --- a/core/expression/tests/slots.rs +++ b/core/expression/tests/slots.rs @@ -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 { + 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()); +} diff --git a/core/expression/tests/unary.rs b/core/expression/tests/unary.rs index 3d1123b7..e4650e29 100644 --- a/core/expression/tests/unary.rs +++ b/core/expression/tests/unary.rs @@ -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}"); + } +} diff --git a/core/expression/tests/variable_type_cycles.rs b/core/expression/tests/variable_type_cycles.rs new file mode 100644 index 00000000..f82171f7 --- /dev/null +++ b/core/expression/tests/variable_type_cycles.rs @@ -0,0 +1,85 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use zen_expression::variable::{VariableMapExt, VariableType}; + +type Fields = ahash::HashMap, 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)); +} diff --git a/core/types/src/variable_type/mod.rs b/core/types/src/variable_type/mod.rs index 30ca01f9..40105ffa 100644 --- a/core/types/src/variable_type/mod.rs +++ b/core/types/src/variable_type/mod.rs @@ -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 = Rc>; -#[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, VariableType>, + path: Vec<*const ()>, +} + +impl Serialize for VariableType { + fn serialize(&self, serializer: S) -> Result { + Guarded { + inner: self, + path: &[], + } + .serialize(serializer) + } +} + +impl Serialize for Guarded<'_> { + fn serialize(&self, serializer: S) -> Result { + 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, 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(&self, serializer: S) -> Result { + 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 diff --git a/core/types/src/variable_type/util.rs b/core/types/src/variable_type/util.rs index 34fb7f4e..4b5ba2d5 100644 --- a/core/types/src/variable_type/util.rs +++ b/core/types/src/variable_type/util.rs @@ -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());