diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index ffe6fe0e..f71b39d5 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -459,8 +459,6 @@ export interface DecisionNode { config: any } -export declare function encodeZenString(value: string): string | null - export interface EvaluateBatchRequest { key: string context: any diff --git a/bindings/nodejs/index.js b/bindings/nodejs/index.js index d4de01a4..bba6e20b 100644 --- a/bindings/nodejs/index.js +++ b/bindings/nodejs/index.js @@ -577,7 +577,6 @@ module.exports.ZenDecision = nativeBinding.ZenDecision module.exports.ZenDecisionContent = nativeBinding.ZenDecisionContent module.exports.ZenEngine = nativeBinding.ZenEngine module.exports.ZenEngineHandlerRequest = nativeBinding.ZenEngineHandlerRequest -module.exports.encodeZenString = nativeBinding.encodeZenString module.exports.evaluateExpression = nativeBinding.evaluateExpression module.exports.evaluateExpressionSync = nativeBinding.evaluateExpressionSync module.exports.evaluateUnaryExpression = nativeBinding.evaluateUnaryExpression diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs index e24ffa76..09de4f62 100644 --- a/bindings/nodejs/src/policy.rs +++ b/bindings/nodejs/src/policy.rs @@ -306,7 +306,6 @@ 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, } @@ -421,7 +420,6 @@ pub(crate) fn variable_type_to_json(vt: &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 ()>, diff --git a/bindings/nodejs/src/slot.rs b/bindings/nodejs/src/slot.rs index 6ee0279c..8ac3ec2a 100644 --- a/bindings/nodejs/src/slot.rs +++ b/bindings/nodejs/src/slot.rs @@ -50,12 +50,10 @@ impl Workspace { pub struct SlotRequest { pub id: String, pub text: String, - /// Caret position in UTF-16 code units. pub pos: u32, pub unary: bool, #[napi(ts_type = "PolicySlotRole")] pub role: String, - /// Root scope; must already contain `$` for unary requests. #[napi(ts_type = "PolicyVariableType")] pub scope: Value, #[napi(ts_type = "PolicyVariableType | null")] @@ -91,11 +89,6 @@ pub fn slot_batch(requests: Vec, strict: Option) -> napi::Res Ok(out) } -#[napi] -pub fn encode_zen_string(value: String) -> Option { - zen_expression::slot::encode_string(&value) -} - fn parse_role(role: &str) -> napi::Result { match role { "unary" => Ok(SlotRole::Unary), diff --git a/bindings/nodejs/test/slot.spec.ts b/bindings/nodejs/test/slot.spec.ts deleted file mode 100644 index c612ab94..00000000 --- a/bindings/nodejs/test/slot.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -const { Workspace, slotBatch, encodeZenString } = require('../index.js'); - -const policy = { - 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: 'customer', - properties: [ - { id: 'p1', name: 'name', type: 'string', array: false, optional: false }, - { id: 'p2', name: 'stage', type: 'relationship', target: 'status', array: false, optional: false }, - ], - }, - }, - children: [], - }, - { - id: 'dt', - type: 'decisionTable', - props: { - data: { - hitPolicy: 'first', - inputs: [ - { id: 'in_stage', name: 'Stage', field: 'customer.stage' }, - { id: 'in_cond', name: 'Condition' }, - ], - outputs: [{ id: 'out', name: 'Out', field: 'customer.out', type: 'status' }], - rules: [ - { _id: 'r1', in_stage: '"open"', in_cond: '', out: '"closed"' }, - { _id: 'r2', in_stage: '', in_cond: '', out: '' }, - ], - }, - }, - children: [], - }, - ], -}; - -const cell = (row: string, col: string, pos: number) => ({ - policyPath: 'p', - blockId: 'dt', - pos, - target: { kind: 'decisionTableCell', row, col }, -}); - -const workspace = () => { - const ws = new Workspace(); - ws.setPolicy('p', policy); - return ws; -}; - -const labelsOf = (options: { label: string }[]) => options.map((o) => o.label); - -describe('Workspace slot', () => { - it('classifies a unary dictionary cell with labels', () => { - const ws = workspace(); - const response = ws.slot(cell('r1', 'in_stage', 4), '== "'); - expect(response).not.toBeNull(); - expect(response.kind).toBe('unary'); - expect(response.role).toBe('unary'); - expect(response.slot.state).toBe('inString'); - expect(response.slot.replaceSpan).toEqual([3, 4]); - expect(response.slot.autoOpen).toBe(true); - expect(labelsOf(response.slot.options)).toEqual(['Open case', 'Closed']); - expect(response.slot.options[0].source).toBe('"open"'); - expect(response.subjectType).toEqual({ type: 'enum', name: 'status', values: ['open', 'closed'] }); - expect(response.expectedType).toBeNull(); - }); - - it('reports UTF-16 offsets with an emoji before the caret', () => { - const ws = workspace(); - const text = 'customer.name == "😀" and customer.stage == "'; - const response = ws.slot(cell('r1', 'in_cond', text.length), text); - expect(response.kind).toBe('standard'); - expect(response.role).toBe('condition'); - expect(response.expectedType).toEqual({ type: 'bool' }); - expect(response.slot.state).toBe('inString'); - expect(response.slot.replaceSpan).toEqual([text.length - 1, text.length]); - expect(labelsOf(response.slot.options)).toEqual(['Open case', 'Closed']); - - const complete = 'customer.name == "😀" and customer.stage == "open"'; - const facts = ws.slot(cell('r1', 'in_cond', complete.length), complete); - const literal = facts.literals.find((f: any) => f.kind === 'enum'); - expect(literal).toMatchObject({ - kind: 'enum', - span: [complete.indexOf('"open"'), complete.length], - label: 'Open case', - valid: true, - }); - }); - - it('returns null outside expression targets', () => { - const ws = workspace(); - expect(ws.slot({ policyPath: 'p', blockId: 'dm', pos: 0, target: { kind: 'dataModelName' } }, 'x')).toBeNull(); - expect(ws.slot({ policyPath: 'missing', blockId: 'dt', pos: 0, target: { kind: 'expression', id: 'x' } }, 'x')).toBeNull(); - }); -}); - -describe('Workspace facts', () => { - it('enumerates table cells with labeled literals and subject options', () => { - const ws = workspace(); - const facts = ws.facts('p'); - expect(facts.length).toBe(7); - - const open = facts.find((f: any) => f.target.row === 'r1' && f.target.col === 'in_stage'); - expect(open.kind).toBe('unary'); - expect(open.source).toBe('"open"'); - expect(open.literals).toEqual([ - { kind: 'enum', span: [0, 6], value: 'open', name: 'status', label: 'Open case', valid: true, enumIndex: 0 }, - ]); - expect(labelsOf(open.subjectOptions)).toEqual(['Open case', 'Closed']); - expect(open.subjectType).toEqual({ type: 'enum', name: 'status', values: ['open', 'closed'] }); - - const empty = facts.find((f: any) => f.target.row === 'r2' && f.target.col === 'out'); - expect(empty.role).toBe('value'); - expect(empty.source).toBe(''); - expect(empty.literals).toEqual([]); - expect(labelsOf(empty.subjectOptions)).toEqual(['Open case', 'Closed']); - expect(empty.expectedType).toEqual({ type: 'enum', name: 'status', values: ['open', 'closed'] }); - - const head = facts.find((f: any) => f.target.kind === 'decisionTableHead'); - expect(head.role).toBe('path'); - expect(ws.facts('missing')).toEqual([]); - }); -}); - -describe('slotBatch', () => { - const status = { type: 'enum', name: 'status', values: ['open', 'closed'] }; - const scope = { type: 'object', fields: { status, age: { type: 'number' } } }; - const labels = { status: { open: 'Open case', closed: 'Closed' } }; - - it('classifies standalone requests with caller-provided labels', () => { - const text = 'name == "😀" and status == "'; - const results = slotBatch([ - { id: 'a', text, pos: text.length, unary: false, role: 'condition', scope, expected: { type: 'bool' }, labels }, - { id: 'b', text: 'age > ', pos: 6, unary: false, role: 'condition', scope, expected: { type: 'bool' }, labels: null }, - { - id: 'c', - text: '== "', - pos: 4, - unary: true, - role: 'unary', - scope: { type: 'object', fields: { $: status, ...scope.fields } }, - expected: null, - labels, - }, - ]); - expect(results.map((r: any) => r.id)).toEqual(['a', 'b', 'c']); - const [a, b, c] = results.map((r: any) => r.result); - expect(a.slot.state).toBe('inString'); - expect(a.slot.replaceSpan).toEqual([text.length - 1, text.length]); - expect(labelsOf(a.slot.options)).toEqual(['Open case', 'Closed']); - expect(b.slot.state).toBe('value'); - expect(b.slot.expected).toEqual({ type: 'number' }); - expect(c.kind).toBe('unary'); - expect(c.subjectType).toEqual(status); - expect(labelsOf(c.slot.options)).toEqual(['Open case', 'Closed']); - }); - - it('rejects unknown roles', () => { - expect(() => slotBatch([{ id: 'x', text: '', pos: 0, unary: false, role: 'nope', scope, expected: null, labels: null }])).toThrow( - /invalid slot role/, - ); - }); -}); - -describe('encodeZenString', () => { - it('picks a quote that does not need escaping', () => { - expect(encodeZenString('open')).toBe('"open"'); - expect(encodeZenString('say "hi"')).toBe('\'say "hi"\''); - expect(encodeZenString('it\'s "x"')).toBeNull(); - }); -}); diff --git a/bindings/nodejs/zen-engine.wasi-browser.js b/bindings/nodejs/zen-engine.wasi-browser.js index b2869954..c0988ef1 100644 --- a/bindings/nodejs/zen-engine.wasi-browser.js +++ b/bindings/nodejs/zen-engine.wasi-browser.js @@ -61,7 +61,6 @@ export const ZenDecision = __napiModule.exports.ZenDecision export const ZenDecisionContent = __napiModule.exports.ZenDecisionContent export const ZenEngine = __napiModule.exports.ZenEngine export const ZenEngineHandlerRequest = __napiModule.exports.ZenEngineHandlerRequest -export const encodeZenString = __napiModule.exports.encodeZenString export const evaluateExpression = __napiModule.exports.evaluateExpression export const evaluateExpressionSync = __napiModule.exports.evaluateExpressionSync export const evaluateUnaryExpression = __napiModule.exports.evaluateUnaryExpression diff --git a/bindings/nodejs/zen-engine.wasi.cjs b/bindings/nodejs/zen-engine.wasi.cjs index 049a9d7b..e2142f86 100644 --- a/bindings/nodejs/zen-engine.wasi.cjs +++ b/bindings/nodejs/zen-engine.wasi.cjs @@ -113,7 +113,6 @@ module.exports.ZenDecision = __napiModule.exports.ZenDecision module.exports.ZenDecisionContent = __napiModule.exports.ZenDecisionContent module.exports.ZenEngine = __napiModule.exports.ZenEngine module.exports.ZenEngineHandlerRequest = __napiModule.exports.ZenEngineHandlerRequest -module.exports.encodeZenString = __napiModule.exports.encodeZenString module.exports.evaluateExpression = __napiModule.exports.evaluateExpression module.exports.evaluateExpressionSync = __napiModule.exports.evaluateExpressionSync module.exports.evaluateUnaryExpression = __napiModule.exports.evaluateUnaryExpression diff --git a/core/engine/src/nodes/input/mod.rs b/core/engine/src/nodes/input/mod.rs index b169cd53..46443db7 100644 --- a/core/engine/src/nodes/input/mod.rs +++ b/core/engine/src/nodes/input/mod.rs @@ -29,7 +29,6 @@ impl NodeHandler for InputNodeHandler { } } -/// A `null` on a property the schema does not require reads as absent, the way policies treat it. fn without_optional_nulls(schema: &Value, value: &Variable) -> Variable { if let (Some(props), Some(obj)) = ( schema.get("properties").and_then(Value::as_object), diff --git a/core/engine/src/policy/ir.rs b/core/engine/src/policy/ir.rs index 2b90de0f..223dc88d 100644 --- a/core/engine/src/policy/ir.rs +++ b/core/engine/src/policy/ir.rs @@ -221,8 +221,7 @@ impl DataModelIr { visited: &mut HashSet>, ) -> VariableType { let inner = match &prop.kind { - PropertyTypeIr::String => VariableType::String, - PropertyTypeIr::Date => VariableType::Date, + PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String, 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 256dd818..0927890e 100644 --- a/core/engine/src/policy/queries/dependency.rs +++ b/core/engine/src/policy/queries/dependency.rs @@ -69,7 +69,6 @@ pub struct EnrichedState { } 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(); @@ -110,7 +109,6 @@ 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, } @@ -582,8 +580,6 @@ 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| { diff --git a/core/engine/src/policy/queries/diagnostics.rs b/core/engine/src/policy/queries/diagnostics.rs index 3b31168e..13571e10 100644 --- a/core/engine/src/policy/queries/diagnostics.rs +++ b/core/engine/src/policy/queries/diagnostics.rs @@ -24,8 +24,6 @@ impl Db { } } - // Independently valid imports can conflict when composed. Surface those - // conflicts on the importing entry, even if neither writer is local. let mut scope_diagnostics = self.graph_diagnostics(path); scope_diagnostics.extend(self.data_model_diagnostics(path)); scope_diagnostics.extend(self.dictionary_diagnostics(path)); diff --git a/core/engine/src/policy/queries/scope.rs b/core/engine/src/policy/queries/scope.rs index a1cde174..1051c06a 100644 --- a/core/engine/src/policy/queries/scope.rs +++ b/core/engine/src/policy/queries/scope.rs @@ -659,7 +659,6 @@ impl PropertyTypeIr { )), PropertyTypeIr::Number => Some(VariableType::Number), PropertyTypeIr::Boolean => Some(VariableType::Bool), - // Declared dates arrive as strings. Only d(...) produces a dynamic date value. PropertyTypeIr::Date => Some(VariableType::String), PropertyTypeIr::Relationship { .. } | PropertyTypeIr::Reference { .. } => None, } diff --git a/core/engine/src/workspace/db.rs b/core/engine/src/workspace/db.rs index 0e065318..5627df36 100644 --- a/core/engine/src/workspace/db.rs +++ b/core/engine/src/workspace/db.rs @@ -453,8 +453,6 @@ impl Db { if let Some(u) = snap.units.borrow().get(policy).cloned() { return u; } - // Connectivity is useful for workspace navigation, but an import only - // makes its dependencies visible. Other importers are separate entries. let mut seen = HashSet::default(); let mut stack = vec![Arc::::from(policy)]; while let Some(path) = stack.pop() { diff --git a/core/engine/src/workspace/editor.rs b/core/engine/src/workspace/editor.rs index abc9b0f7..1d5ef7c4 100644 --- a/core/engine/src/workspace/editor.rs +++ b/core/engine/src/workspace/editor.rs @@ -1,13 +1,12 @@ use std::sync::Arc; -use crate::workspace::slot::utf16; use ahash::{HashMap, HashMapExt}; use serde_json::Value; use zen_expression::intellisense::completion::Completions; use zen_expression::intellisense::Reference; use zen_expression::variable::VariableType; -use crate::policy::blocks::IntelliSenseSource; +use crate::policy::blocks::{IntelliSenseSource, ROW_ID_KEY}; use crate::policy::ir::{DataModelIr, PropertyTypeIr}; use crate::policy::queries::scope::EntityGraph; use crate::policy::raw::BlockDoc; @@ -22,11 +21,11 @@ impl Db { let (source, _, scope) = self.resolve_cursor(cursor)?; let r = self.cursor_intellisense(cursor).borrow_mut().inspect( &source, - utf16::utf16_to_byte(&source, cursor.pos) as u32, + SpanOps::byte_offset(&source, cursor.pos) as u32, &scope, )?; Some(InspectResult { - span: utf16::utf16_span(&source, r.span), + span: SpanOps::char_span(&source, r.span), kind: r.kind, label: r.label, }) @@ -39,7 +38,7 @@ impl Db { let Some(scope) = self.cursor_scope(cursor) else { return Vec::new(); }; - let len = source.encode_utf16().count() as u32; + let len = SpanOps::char_len(&source); let padded; let source: &str = if cursor.pos > len { padded = format!("{source}{}", " ".repeat((cursor.pos - len) as usize)); @@ -47,7 +46,7 @@ impl Db { } else { &source }; - let pos = utf16::utf16_to_byte(source, cursor.pos) as u32; + let pos = SpanOps::byte_offset(source, cursor.pos) as u32; let result = self.cursor_intellisense(cursor).borrow_mut().slot( source, pos, @@ -249,7 +248,7 @@ impl Db { ) => data .rules .iter() - .find(|r| r.get("_id") == Some(row))? + .find(|r| r.get(ROW_ID_KEY) == Some(row))? .get(col) .cloned() .unwrap_or_else(|| Arc::from("")), diff --git a/core/engine/src/workspace/graph/schema.rs b/core/engine/src/workspace/graph/schema.rs index b6c55269..702c7a73 100644 --- a/core/engine/src/workspace/graph/schema.rs +++ b/core/engine/src/workspace/graph/schema.rs @@ -25,36 +25,6 @@ impl SchemaType { Self::resolve::(schema, dictionaries) } - pub(crate) fn is_date_path(schema: &Value, path: &str) -> bool { - if let Some(cases) = schema - .get("anyOf") - .or_else(|| schema.get("oneOf")) - .and_then(Value::as_array) - { - let mut known = cases - .iter() - .filter(|case| case.get("type").and_then(Value::as_str) != Some("null")); - return known - .next() - .is_some_and(|case| Self::is_date_path(case, path)) - && known.all(|case| Self::is_date_path(case, path)); - } - if let Some(items) = schema.get("items") { - return Self::is_date_path(items, path); - } - if path.is_empty() { - return matches!( - schema.get("format").and_then(Value::as_str), - Some("date" | "date-time") - ); - } - let (field, rest) = path.split_once('.').unwrap_or((path, "")); - schema - .get("properties") - .and_then(|props| props.get(field)) - .is_some_and(|child| Self::is_date_path(child, rest)) - } - fn resolve( schema: &Value, dictionaries: &SchemaDictionaries, diff --git a/core/engine/src/workspace/slot/facts.rs b/core/engine/src/workspace/slot/facts.rs index ccbda9e7..511b1c1c 100644 --- a/core/engine/src/workspace/slot/facts.rs +++ b/core/engine/src/workspace/slot/facts.rs @@ -1,21 +1,18 @@ use std::sync::Arc; use serde::Serialize; -use zen_expression::slot::{subject_enum_options, EnumTable, LiteralFact, SlotRole, ValueOption}; +use zen_expression::slot::{Literals, SlotRole, ValueOption}; use zen_expression::variable::VariableType; -use super::fact_utf16; use crate::policy::blocks::ROW_ID_KEY; use crate::policy::raw::BlockDoc; use crate::workspace::db::Db; use crate::workspace::graph::GraphAnalyzer; -use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind}; +use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind, SpanOps}; -/// Literal facts for one expression location; spans are UTF-16 code units. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExpressionFacts { - pub complete: bool, pub block_id: Arc, pub target: CursorTarget, pub source: Arc, @@ -23,8 +20,8 @@ pub struct ExpressionFacts { pub role: SlotRole, pub subject_type: Option, pub expected_type: Option, - pub literals: Vec, - pub enums: Vec, + #[serde(flatten)] + pub literals: Literals, pub subject_options: Vec, } @@ -39,9 +36,9 @@ impl Db { let path: Arc = Arc::from(policy); let graph = self.is_graph(policy); let sites = if graph { - graph_sites(self, &path) + self.graph_sites(&path) } else { - policy_sites(self, &path) + self.policy_sites(&path) }; if sites.is_empty() { return Vec::new(); @@ -65,31 +62,28 @@ impl Db { let Some(scope) = self.cursor_scope_cached(&cursor, &mut cache) else { continue; }; - let unary = matches!(scope.kind, ExpressionKind::Unary); let subject_type = scope.subject_type(); let subject_options = subject_type .as_ref() - .and_then(|t| subject_enum_options(t, labels.as_ref())) + .and_then(|t| ValueOption::for_type(t, labels.as_ref())) .unwrap_or_default(); - let (literals, enums, complete) = intellisense.borrow_mut().literal_analysis( - &site.source, - unary, - &scope.scope, - scope.expected.as_ref(), - ); + let literals = intellisense + .borrow_mut() + .literals( + &site.source, + scope.is_unary(), + &scope.scope, + scope.expected.as_ref(), + ) + .map_spans(|span| SpanOps::char_span(&site.source, span)); out.push(ExpressionFacts { - complete, block_id: site.block_id, target: site.target, kind: scope.kind, role: scope.role, subject_type, expected_type: scope.expected, - literals: literals - .into_iter() - .map(|f| fact_utf16(&site.source, f)) - .collect(), - enums, + literals, subject_options, source: site.source, }); @@ -97,114 +91,114 @@ impl Db { intellisense.borrow_mut().set_labels(None); out } -} -/// Sites carry the document text verbatim, not the trimmed IR text, so facts match the live cell. -fn policy_sites(db: &Db, path: &Arc) -> Vec { - let Some(policy) = db.raw_policy(path) else { - return Vec::new(); - }; - let mut sites = Vec::new(); - for block in &policy.blocks { - let Some(block_id) = block.id() else { - continue; + fn policy_sites(&self, path: &Arc) -> Vec { + let Some(policy) = self.raw_policy(path) else { + return Vec::new(); }; - let block_id: Arc = Arc::from(block_id); - let mut push = |target: CursorTarget, source: &Arc| { - sites.push(Site { - block_id: block_id.clone(), - target, - source: source.clone(), - }); - }; - match block { - BlockDoc::DecisionTable { data: table, .. } => { - for col in &table.inputs { - if let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) { - push( - CursorTarget::DecisionTableHead { - col: col.id.clone(), - }, - field, - ); + let mut sites = Vec::new(); + for block in &policy.blocks { + let Some(block_id) = block.id() else { + continue; + }; + let block_id: Arc = Arc::from(block_id); + let mut push = |target: CursorTarget, source: &Arc| { + sites.push(Site { + block_id: block_id.clone(), + target, + source: source.clone(), + }); + }; + match block { + BlockDoc::DecisionTable { data: table, .. } => { + for col in &table.inputs { + if let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) { + push( + CursorTarget::DecisionTableHead { + col: col.id.clone(), + }, + field, + ); + } + } + let empty: Arc = Arc::from(""); + for rule in &table.rules { + let Some(row) = rule.get(ROW_ID_KEY) else { + continue; + }; + let ids = table + .inputs + .iter() + .map(|c| &c.id) + .chain(table.outputs.iter().map(|c| &c.id)); + for col in ids { + push( + CursorTarget::DecisionTableCell { + row: row.clone(), + col: col.clone(), + }, + rule.get(col).unwrap_or(&empty), + ); + } } } - let empty: Arc = Arc::from(""); - for rule in &table.rules { - let Some(row) = rule.get(ROW_ID_KEY) else { - continue; - }; - let ids = table - .inputs - .iter() - .map(|c| &c.id) - .chain(table.outputs.iter().map(|c| &c.id)); - for col in ids { - push( - CursorTarget::DecisionTableCell { - row: row.clone(), - col: col.clone(), - }, - rule.get(col).unwrap_or(&empty), - ); + BlockDoc::Expression { id, data } => { + if !data.value.is_empty() { + push(CursorTarget::Expression { id: id.clone() }, &data.value); } } + BlockDoc::Assertion { data, .. } => { + for condition in &data.conditions { + if !condition.expression.is_empty() { + push( + CursorTarget::Expression { + id: condition.id.clone(), + }, + &condition.expression, + ); + } + } + } + BlockDoc::Match { data, .. } => { + if !data.key.is_empty() { + push(CursorTarget::MatchTarget, &data.key); + } + for arm in &data.arms { + if !arm.condition.is_empty() { + push( + CursorTarget::Expression { id: arm.id.clone() }, + &arm.condition, + ); + } + if !arm.value.is_empty() { + push(CursorTarget::MatchValue { id: arm.id.clone() }, &arm.value); + } + } + } + BlockDoc::DataModel { .. } | BlockDoc::Dictionary { .. } | BlockDoc::Ignored(_) => { + } } - BlockDoc::Expression { id, data } => { - if !data.value.is_empty() { - push(CursorTarget::Expression { id: id.clone() }, &data.value); - } - } - BlockDoc::Assertion { data, .. } => { - for condition in &data.conditions { - if !condition.expression.is_empty() { - push( - CursorTarget::Expression { - id: condition.id.clone(), - }, - &condition.expression, - ); - } - } - } - BlockDoc::Match { data, .. } => { - if !data.key.is_empty() { - push(CursorTarget::MatchTarget, &data.key); - } - for arm in &data.arms { - if !arm.condition.is_empty() { - push( - CursorTarget::Expression { id: arm.id.clone() }, - &arm.condition, - ); - } - if !arm.value.is_empty() { - push(CursorTarget::MatchValue { id: arm.id.clone() }, &arm.value); - } - } - } - BlockDoc::DataModel { .. } | BlockDoc::Dictionary { .. } | BlockDoc::Ignored(_) => {} } + sites } - sites -} -fn graph_sites(db: &Db, path: &Arc) -> Vec { - let snap = db.snapshot(); - let Some(content) = snap.graphs.get(path).and_then(|doc| doc.as_graph()) else { - return Vec::new(); - }; - content - .nodes - .iter() - .flat_map(|node| { - GraphAnalyzer::node_sites(node) - .into_iter() - .map(move |site| Site { - block_id: node.id.clone(), - target: site.target, - source: site.source, - }) - }) - .collect() + fn graph_sites(&self, path: &Arc) -> Vec { + let snap = self.snapshot(); + let Some(content) = snap.graphs.get(path).and_then(|doc| doc.as_graph()) else { + return Vec::new(); + }; + content + .nodes + .iter() + .flat_map(|node| { + GraphAnalyzer::node_sites(node) + .into_iter() + .map(move |site| Site { + block_id: node.id.clone(), + target: site.target, + source: site.source, + }) + }) + .collect() + } } diff --git a/core/engine/src/workspace/slot/graph.rs b/core/engine/src/workspace/slot/graph.rs deleted file mode 100644 index 0b6d4876..00000000 --- a/core/engine/src/workspace/slot/graph.rs +++ /dev/null @@ -1,212 +0,0 @@ -use std::sync::Arc; - -use zen_expression::variable::VariableType; -use zen_types::decision::{DecisionNode, DecisionNodeKind, DecisionTableContent}; - -use super::siblings::SiblingCache; -use super::{collected_type, date_hint, is_date_type, known_type, CursorScope}; -use crate::model::GraphContent; -use crate::policy::blocks::IntelliSenseSource; -use crate::policy::queries::scope::VariableTypeScope; -use crate::workspace::db::Db; -use crate::workspace::graph::{GraphAnalyzer, GraphNodeAnalysis, SchemaType}; -use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind}; - -pub(super) fn graph_scope( - db: &Db, - cursor: &Cursor, - cache: &mut SiblingCache, -) -> Option { - let snap = db.snapshot(); - let doc = snap.graphs.get(&cursor.policy_path)?.clone(); - let content = doc.as_graph()?; - let analysis = db.graph_analysis(&cursor.policy_path)?; - let node = content.nodes.iter().find(|n| n.id == cursor.block_id)?; - let node_analysis = analysis.nodes.get(&cursor.block_id)?; - - if matches!(cursor.target, CursorTarget::TransformInput) { - if !has_transform_attributes(node) { - return None; - } - return Some(CursorScope::path(GraphAnalyzer::scope_with_nodes( - &node_analysis.input, - &node_analysis.nodes_scope, - ))); - } - - 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; - }; - let dollar = node_analysis - .dollar - .clone() - .unwrap_or_else(VariableType::empty_object); - let scope = GraphAnalyzer::scope_with( - &node_analysis.handler_input, - &[ - ("$", dollar), - ("$nodes", node_analysis.nodes_scope.shallow_clone()), - ], - ); - let expected = rows - .expressions - .iter() - .find(|row| row.id == *id) - .filter(|row| !row.key.is_empty()) - .and_then(|row| { - output_schema_type( - db, - content, - &row.key, - rows.transform_attributes.output_path.as_deref(), - ) - }); - Some(CursorScope::value(scope, expected)) - } - DecisionNodeKind::SwitchNode { .. } => { - let CursorTarget::Expression { .. } = &cursor.target else { - return None; - }; - Some(CursorScope::condition(GraphAnalyzer::scope_with_nodes( - &node_analysis.input, - &node_analysis.nodes_scope, - ))) - } - DecisionNodeKind::DecisionTableNode { content: table } => { - table_scope(db, content, table, node_analysis, cursor, cache) - } - _ => None, - } -} - -fn table_scope( - db: &Db, - content: &GraphContent, - table: &DecisionTableContent, - node_analysis: &GraphNodeAnalysis, - cursor: &Cursor, - cache: &mut SiblingCache, -) -> Option { - let scope = - GraphAnalyzer::scope_with_nodes(&node_analysis.handler_input, &node_analysis.nodes_scope); - match &cursor.target { - CursorTarget::DecisionTableHead { col } => { - let known = table.inputs.iter().any(|c| c.id == *col) - || table.outputs.iter().any(|c| c.id == *col); - known.then(|| CursorScope::path(scope)) - } - CursorTarget::DecisionTableCell { 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) => { - let field_type = return_type(db, field, &scope); - let path = match table - .transform_attributes - .input_field - .as_deref() - .filter(|p| !p.is_empty()) - { - Some(prefix) => format!("{prefix}.{field}"), - None => field.to_string(), - }; - let hint = content - .nodes - .iter() - .find_map(|node| match &node.kind { - DecisionNodeKind::InputNode { content } => content.schema.as_ref(), - _ => None, - }) - .filter(|schema| SchemaType::is_date_path(schema, &path)) - .map(|_| date_hint(field_type.shallow_clone())) - .filter(is_date_type); - CursorScope::unary_with_hint(scope.with_dollar(&field_type), hint) - } - None => CursorScope::condition(scope), - }); - } - 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.strip_suffix("[]").unwrap_or(&column.field), - table.transform_attributes.output_path.as_deref(), - ) - .map(|t| { - // The collect hit policy wraps rows, which resolve_at already - // traverses. Only a column marker wraps this field's value. - collected_type(t, column.field.ends_with("[]")) - }) - }) - .flatten() - }) - .or_else(|| { - cache.infer(cursor, || { - table - .rules - .iter() - .enumerate() - .map(|(index, rule)| { - let id = GraphAnalyzer::row_key(rule, index); - let t = rule - .get(col) - .filter(|cell| !cell.is_empty()) - .map(|cell| return_type(db, cell, &scope)); - (id, t) - }) - .collect() - }) - }); - Some(CursorScope::value(scope, expected)) - } - _ => None, - } -} - -fn output_schema_type( - db: &Db, - content: &GraphContent, - key: &str, - output_path: Option<&str>, -) -> Option { - let schema = content.nodes.iter().find_map(|node| match &node.kind { - DecisionNodeKind::OutputNode { content } => content.schema.as_ref(), - _ => None, - })?; - let dictionaries = db.graph_dictionary_types(&content.imports); - let output = SchemaType::hint_type_with(schema, &dictionaries); - let key = match output_path.filter(|p| !p.is_empty()) { - Some(path) => format!("{path}.{key}"), - None => key.to_string(), - }; - known_type(output.resolve_at(&key)) -} - -fn has_transform_attributes(node: &DecisionNode) -> bool { - matches!( - node.kind, - DecisionNodeKind::ExpressionNode { .. } - | DecisionNodeKind::DecisionTableNode { .. } - | DecisionNodeKind::DecisionNode { .. } - ) -} - -fn return_type(db: &Db, source: &Arc, scope: &VariableType) -> VariableType { - let intellisense = db.graph_intellisense(); - let mut is = intellisense.borrow_mut(); - IntelliSenseSource::analyze(&mut is, source, ExpressionKind::Standard, scope) - .return_type - .shallow_clone() -} diff --git a/core/engine/src/workspace/slot/mod.rs b/core/engine/src/workspace/slot/mod.rs index 2e5d16c9..eea0bc1c 100644 --- a/core/engine/src/workspace/slot/mod.rs +++ b/core/engine/src/workspace/slot/mod.rs @@ -1,8 +1,6 @@ mod facts; -mod graph; -mod policy; +mod scope; mod siblings; -pub(crate) mod utf16; use std::cell::RefCell; use std::rc::Rc; @@ -11,91 +9,52 @@ use std::sync::Arc; use ahash::{HashMap, HashMapExt}; use serde::Serialize; use zen_expression::intellisense::IntelliSense; -use zen_expression::slot::{is_date_type, EnumTable, LabelResolver, LiteralFact, Slot, SlotResult}; +use zen_expression::slot::{LabelResolver, Literals, Slot, SlotResult}; use zen_expression::variable::VariableType; use crate::policy::ir::DictionaryIr; use crate::workspace::db::{Db, Unit}; use crate::workspace::graph::GraphAnalysis; -use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind}; +use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind, SpanOps}; pub use facts::ExpressionFacts; pub use zen_expression::slot::SlotRole; -/// Slot at the caret plus literal facts for the live text; `pos` and spans are UTF-16 code units. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SlotResponse { - pub complete: bool, pub kind: ExpressionKind, pub role: SlotRole, pub subject_type: Option, pub expected_type: Option, pub slot: Slot, - pub literals: Vec, - pub enums: Vec, + #[serde(flatten)] + pub literals: Literals, } impl SlotResponse { pub fn compute(is: &mut IntelliSense, scope: &CursorScope, text: &str, pos: u32) -> Self { - let unary = matches!(scope.kind, ExpressionKind::Unary); - let pos = utf16::utf16_to_byte(text, pos) as u32; - let SlotResult { - mut slot, - complete, - literals, - enums, - .. - } = is.slot( + let pos = SpanOps::byte_offset(text, pos) as u32; + let SlotResult { mut slot, literals } = is.slot( text, pos, - unary, + scope.is_unary(), scope.role, &scope.scope, scope.expected.as_ref(), ); - slot.replace_span = utf16::utf16_span(text, slot.replace_span); + slot.replace_span = SpanOps::char_span(text, slot.replace_span); Self { - complete, kind: scope.kind, role: scope.role, subject_type: scope.subject_type(), expected_type: scope.expected.as_ref().map(VariableType::shallow_clone), slot, - literals: literals.into_iter().map(|f| fact_utf16(text, f)).collect(), - enums, + literals: literals.map_spans(|span| SpanOps::char_span(text, span)), } } } -pub(crate) fn fact_utf16(text: &str, fact: LiteralFact) -> LiteralFact { - match fact { - LiteralFact::Enum { - span, - value, - name, - label, - valid, - enum_index, - } => LiteralFact::Enum { - span: utf16::utf16_span(text, span), - value, - name, - label, - valid, - enum_index, - }, - LiteralFact::Date { span, arg } => LiteralFact::Date { - span: utf16::utf16_span(text, span), - arg, - }, - LiteralFact::Bool { span, value } => LiteralFact::Bool { - span: utf16::utf16_span(text, span), - value, - }, - } -} - type LabelMap = HashMap, HashMap, Arc>>; enum LabelKey { @@ -120,22 +79,24 @@ pub(crate) struct LabelCache { entries: RefCell, LabelEntry>>, } -fn label_map( - dictionaries: impl Iterator, Arc)>, -) -> Option> { - let mut labels = LabelMap::new(); - for (name, dict) in dictionaries { - let entries: HashMap, Arc> = dict - .entries - .iter() - .filter(|e| !e.label.is_empty()) - .map(|e| (e.value.clone(), e.label.clone())) - .collect(); - if !entries.is_empty() { - labels.insert(name, entries); +impl LabelCache { + fn label_map( + dictionaries: impl Iterator, Arc)>, + ) -> Option> { + let mut labels = LabelMap::new(); + for (name, dict) in dictionaries { + let entries: HashMap, Arc> = dict + .entries + .iter() + .filter(|e| !e.label.is_empty()) + .map(|e| (e.value.clone(), e.label.clone())) + .collect(); + if !entries.is_empty() { + labels.insert(name, entries); + } } + (!labels.is_empty()).then(|| Arc::new(labels)) } - (!labels.is_empty()).then(|| Arc::new(labels)) } impl Db { @@ -168,12 +129,12 @@ impl Db { Some(map) => map, None => { let map = match &key { - LabelKey::Graph(_) => label_map( + LabelKey::Graph(_) => LabelCache::label_map( self.graph_dictionary_blocks(&self.graph_imports(policy)) .into_iter() .map(|entry| (entry.ir.name.clone(), entry.ir)), ), - LabelKey::Unit(unit) => label_map( + LabelKey::Unit(unit) => LabelCache::label_map( unit.dictionaries .iter() .map(|(name, dict)| (name.clone(), dict.clone())), @@ -210,13 +171,6 @@ impl CursorScope { } } - fn unary_with_hint(scope: VariableType, expected: Option) -> Self { - Self { - expected, - ..Self::unary(scope) - } - } - fn condition(scope: VariableType) -> Self { Self { kind: ExpressionKind::Standard, @@ -244,18 +198,58 @@ impl CursorScope { } } + pub fn is_unary(&self) -> bool { + matches!(self.kind, ExpressionKind::Unary) + } + pub fn subject_type(&self) -> Option { match self.kind { - ExpressionKind::Unary => Some( - self.expected - .as_ref() - .filter(|hint| is_date_type(hint)) - .map(VariableType::shallow_clone) - .unwrap_or_else(|| self.scope.get("$")), - ), + ExpressionKind::Unary => Some(self.scope.get("$")), ExpressionKind::Standard => self.expected.as_ref().map(VariableType::shallow_clone), } } + + pub(super) fn known_type(resolved: VariableType) -> Option { + let (base, _) = resolved.unwrap_nullable(); + match base { + VariableType::Any | VariableType::Null => None, + _ => Some(resolved), + } + } + + pub(super) fn date_hint(kind: VariableType) -> VariableType { + match kind { + VariableType::String => VariableType::Date, + VariableType::Array(inner) => Self::date_hint(inner.shallow_clone()).array(), + VariableType::Nullable(inner) => { + VariableType::Nullable(Rc::new(Self::date_hint(inner.shallow_clone()))) + } + other => other, + } + } + + pub(super) fn literal_union(merged: VariableType) -> Option { + let (base, _) = merged.unwrap_nullable(); + match base { + VariableType::Enum(..) + | VariableType::Const(_) + | VariableType::Bool + | VariableType::Number + | VariableType::Date => Some(base.shallow_clone()), + _ => None, + } + } + + pub(super) fn collected_type(value: VariableType, collect: bool) -> VariableType { + if collect { + value + .iterator() + .map(|t| t.as_ref().shallow_clone()) + .unwrap_or(value) + } else { + value + } + } } impl Db { @@ -275,51 +269,9 @@ impl Db { return None; } if self.is_graph(&cursor.policy_path) { - graph::graph_scope(self, cursor, cache) + self.graph_cursor_scope(cursor, cache) } else { - policy::policy_scope(self, cursor, cache) + self.policy_cursor_scope(cursor, cache) } } } - -fn known_type(resolved: VariableType) -> Option { - let (base, _) = resolved.unwrap_nullable(); - match base { - VariableType::Any | VariableType::Null => None, - _ => Some(resolved), - } -} - -fn date_hint(kind: VariableType) -> VariableType { - match kind { - VariableType::String => VariableType::Date, - VariableType::Array(inner) => date_hint(inner.shallow_clone()).array(), - VariableType::Nullable(inner) => { - VariableType::Nullable(Rc::new(date_hint(inner.shallow_clone()))) - } - other => other, - } -} - -fn literal_union(merged: VariableType) -> Option { - let (base, _) = merged.unwrap_nullable(); - match base { - VariableType::Enum(..) - | VariableType::Const(_) - | VariableType::Bool - | VariableType::Number - | VariableType::Date => Some(base.shallow_clone()), - _ => None, - } -} - -fn collected_type(value: VariableType, collect: bool) -> VariableType { - if collect { - value - .iterator() - .map(|t| t.as_ref().shallow_clone()) - .unwrap_or(value) - } else { - value - } -} diff --git a/core/engine/src/workspace/slot/policy.rs b/core/engine/src/workspace/slot/policy.rs deleted file mode 100644 index bbe0de05..00000000 --- a/core/engine/src/workspace/slot/policy.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::rc::Rc; -use std::sync::Arc; - -use zen_expression::variable::VariableType; - -use super::siblings::SiblingCache; -use super::{collected_type, date_hint, is_date_type, known_type, literal_union, CursorScope}; -use crate::policy::blocks::{ - BlockKind, DecisionTableIr, ExpressionIr, IntelliSenseSource, MatchIr, ROW_ID_KEY, -}; -use crate::policy::ir::PropertyTypeIr; -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, - cache: &mut SiblingCache, -) -> Option { - 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 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, &enriched.scope, cache) - } - BlockKind::Expression(expression) => expression_scope(db, &unit, expression, cursor, scope), - BlockKind::Assertion(_) => assertion_scope(cursor, scope), - BlockKind::Match(block) => match_scope(db, &unit, block, cursor, scope), - } -} - -fn table_scope( - db: &Db, - unit: &Unit, - table: &DecisionTableIr, - cursor: &Cursor, - scope: VariableType, - written: &VariableType, - cache: &mut SiblingCache, -) -> Option { - match &cursor.target { - CursorTarget::DecisionTableHead { col } => { - let known = table.inputs.iter().any(|c| c.id == *col) - || table.outputs.iter().any(|c| c.id == *col); - known.then(|| CursorScope::path(scope)) - } - CursorTarget::DecisionTableCell { 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) => { - // Calendar hints use the declared date format, while expression - // diagnostics/completions keep the actual string runtime type. - let field_type = return_type(db, field, &scope); - let hint = declared_type(db, unit, field).filter(is_date_type); - CursorScope::unary_with_hint(scope.with_dollar(&field_type), hint) - } - None => CursorScope::condition(scope), - }); - } - let column = table.outputs.iter().find(|c| c.id == *col)?; - let expected = column - .declared - .as_ref() - .and_then(|declared| declared.resolve(&unit.dictionary_types())) - .or_else(|| { - written_type(db, unit, written, column.field.as_ref()) - .map(|t| collected_type(t, column.collect)) - }) - .or_else(|| { - cache.infer(cursor, || { - table - .rules - .iter() - .filter_map(|rule| { - let id = rule.get(ROW_ID_KEY)?.clone(); - let t = rule - .get(col) - .filter(|cell| !cell.is_empty()) - .map(|cell| return_type(db, cell, &scope)); - Some((id, t)) - }) - .collect() - }) - }); - Some(CursorScope::value(scope, expected)) - } - _ => None, - } -} - -fn expression_scope( - db: &Db, - unit: &Unit, - expression: &ExpressionIr, - cursor: &Cursor, - scope: VariableType, -) -> Option { - match &cursor.target { - CursorTarget::ExpressionKey => Some(CursorScope::path(scope)), - CursorTarget::Expression { .. } => { - let expected = declared_type(db, unit, &expression.key); - Some(CursorScope::value(scope, expected)) - } - _ => None, - } -} - -fn assertion_scope(cursor: &Cursor, scope: VariableType) -> Option { - match &cursor.target { - CursorTarget::AssertionOutput => Some(CursorScope::path(scope)), - CursorTarget::Expression { .. } => Some(CursorScope::condition(scope)), - _ => None, - } -} - -fn match_scope( - db: &Db, - unit: &Unit, - block: &MatchIr, - cursor: &Cursor, - scope: VariableType, -) -> Option { - match &cursor.target { - CursorTarget::MatchTarget => Some(CursorScope::path(scope)), - CursorTarget::Expression { .. } => Some(CursorScope::condition(scope)), - CursorTarget::MatchValue { .. } => { - // Output arms define the inferred type; neighboring literals are not a - // declaration constraining what a new arm may return. - let expected = declared_type(db, unit, &block.key); - Some(CursorScope::value(scope, expected)) - } - _ => None, - } -} - -fn return_type(db: &Db, source: &Arc, scope: &VariableType) -> VariableType { - let intellisense = db.intellisense(); - let mut is = intellisense.borrow_mut(); - IntelliSenseSource::analyze(&mut is, source, ExpressionKind::Standard, scope) - .return_type - .shallow_clone() -} - -fn declared_type(db: &Db, unit: &Unit, path: &str) -> Option { - if path.is_empty() { - return None; - } - let kind = known_type(db.snapshot().base_scope.resolve_at(path))?; - let segments: Vec> = path.split('.').map(Rc::from).collect(); - let (field, parent) = segments.split_last()?; - let property = if parent.is_empty() { - unit.entity_graph.global_property(field) - } else { - unit.entity_graph - .resolve_path_to_element(parent) - .and_then(|entity| unit.entities.get(&entity)) - .and_then(|model| { - model - .properties - .iter() - .find(|prop| prop.name.as_ref() == field.as_ref()) - }) - }; - Some( - if property.is_some_and(|prop| matches!(prop.kind, PropertyTypeIr::Date)) { - date_hint(kind) - } else { - kind - }, - ) -} - -fn written_type(db: &Db, unit: &Unit, scope: &VariableType, path: &str) -> Option { - if path.is_empty() { - return None; - } - declared_type(db, unit, path).or_else(|| literal_union(scope.resolve_at(path))) -} diff --git a/core/engine/src/workspace/slot/scope.rs b/core/engine/src/workspace/slot/scope.rs new file mode 100644 index 00000000..7a4596cf --- /dev/null +++ b/core/engine/src/workspace/slot/scope.rs @@ -0,0 +1,313 @@ +use std::rc::Rc; +use std::sync::Arc; + +use zen_expression::variable::VariableType; +use zen_types::decision::{DecisionNodeKind, DecisionTableContent}; + +use super::siblings::SiblingCache; +use super::CursorScope; +use crate::model::GraphContent; +use crate::policy::blocks::{ + BlockKind, DecisionTableIr, IntelliSenseSource, SharedIntelliSense, ROW_ID_KEY, +}; +use crate::policy::ir::PropertyTypeIr; +use crate::policy::queries::scope::VariableTypeScope; +use crate::workspace::db::{Db, Unit}; +use crate::workspace::graph::{GraphAnalyzer, GraphNodeAnalysis, SchemaType}; +use crate::workspace::types::{BlockRef, Cursor, CursorTarget, ExpressionKind}; + +impl Db { + pub(super) fn policy_cursor_scope( + &self, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let block_ref = BlockRef { + policy_path: cursor.policy_path.clone(), + block_id: cursor.block_id.clone(), + }; + let block = self.block_ir(&block_ref)?; + let unit = self.unit(&cursor.policy_path); + let enriched = self.enriched_of_unit(&unit); + let scope = enriched.scope_before(&block_ref); + match (&block.kind, &cursor.target) { + (BlockKind::DecisionTable(table), _) => { + self.policy_table_scope(&unit, table, cursor, scope, &enriched.scope, cache) + } + (BlockKind::Expression(_), CursorTarget::ExpressionKey) + | (BlockKind::Assertion(_), CursorTarget::AssertionOutput) + | (BlockKind::Match(_), CursorTarget::MatchTarget) => Some(CursorScope::path(scope)), + (BlockKind::Expression(expression), CursorTarget::Expression { .. }) => Some( + CursorScope::value(scope, self.declared_type(&unit, &expression.key)), + ), + (BlockKind::Match(block), CursorTarget::MatchValue { .. }) => Some(CursorScope::value( + scope, + self.declared_type(&unit, &block.key), + )), + (BlockKind::Assertion(_) | BlockKind::Match(_), CursorTarget::Expression { .. }) => { + Some(CursorScope::condition(scope)) + } + _ => None, + } + } + + fn policy_table_scope( + &self, + unit: &Unit, + table: &DecisionTableIr, + cursor: &Cursor, + scope: VariableType, + written: &VariableType, + cache: &mut SiblingCache, + ) -> Option { + match &cursor.target { + CursorTarget::DecisionTableHead { col } => { + let known = table.inputs.iter().any(|c| c.id == *col) + || table.outputs.iter().any(|c| c.id == *col); + known.then(|| CursorScope::path(scope)) + } + CursorTarget::DecisionTableCell { 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) => { + let field_type = Self::return_type(&self.intellisense(), field, &scope); + CursorScope::unary(scope.with_dollar(&field_type)) + } + None => CursorScope::condition(scope), + }); + } + let column = table.outputs.iter().find(|c| c.id == *col)?; + let expected = column + .declared + .as_ref() + .and_then(|declared| declared.resolve(&unit.dictionary_types())) + .or_else(|| { + self.written_type(unit, written, column.field.as_ref()) + .map(|t| CursorScope::collected_type(t, column.collect)) + }) + .or_else(|| { + cache.infer(cursor, || { + let is = self.intellisense(); + table + .rules + .iter() + .filter_map(|rule| { + let id = rule.get(ROW_ID_KEY)?.clone(); + let t = rule + .get(col) + .filter(|cell| !cell.is_empty()) + .map(|cell| Self::return_type(&is, cell, &scope)); + Some((id, t)) + }) + .collect() + }) + }); + Some(CursorScope::value(scope, expected)) + } + _ => None, + } + } + + fn declared_type(&self, unit: &Unit, path: &str) -> Option { + if path.is_empty() { + return None; + } + let kind = CursorScope::known_type(self.snapshot().base_scope.resolve_at(path))?; + let segments: Vec> = path.split('.').map(Rc::from).collect(); + let (field, parent) = segments.split_last()?; + let property = if parent.is_empty() { + unit.entity_graph.global_property(field) + } else { + unit.entity_graph + .resolve_path_to_element(parent) + .and_then(|entity| unit.entities.get(&entity)) + .and_then(|model| { + model + .properties + .iter() + .find(|prop| prop.name.as_ref() == field.as_ref()) + }) + }; + Some( + if property.is_some_and(|prop| matches!(prop.kind, PropertyTypeIr::Date)) { + CursorScope::date_hint(kind) + } else { + kind + }, + ) + } + + fn written_type(&self, unit: &Unit, scope: &VariableType, path: &str) -> Option { + if path.is_empty() { + return None; + } + self.declared_type(unit, path) + .or_else(|| CursorScope::literal_union(scope.resolve_at(path))) + } + + pub(super) fn graph_cursor_scope( + &self, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let snap = self.snapshot(); + let doc = snap.graphs.get(&cursor.policy_path)?.clone(); + let content = doc.as_graph()?; + let analysis = self.graph_analysis(&cursor.policy_path)?; + let node = content.nodes.iter().find(|n| n.id == cursor.block_id)?; + let node_analysis = analysis.nodes.get(&cursor.block_id)?; + let input_scope = + || GraphAnalyzer::scope_with_nodes(&node_analysis.input, &node_analysis.nodes_scope); + + match (&node.kind, &cursor.target) { + ( + DecisionNodeKind::ExpressionNode { .. } + | DecisionNodeKind::DecisionTableNode { .. } + | DecisionNodeKind::DecisionNode { .. }, + CursorTarget::TransformInput, + ) + | (DecisionNodeKind::ExpressionNode { .. }, CursorTarget::ExpressionKey) => { + Some(CursorScope::path(input_scope())) + } + ( + DecisionNodeKind::ExpressionNode { content: rows }, + CursorTarget::Expression { id }, + ) => { + let dollar = node_analysis + .dollar + .clone() + .unwrap_or_else(VariableType::empty_object); + let scope = GraphAnalyzer::scope_with( + &node_analysis.handler_input, + &[ + ("$", dollar), + ("$nodes", node_analysis.nodes_scope.shallow_clone()), + ], + ); + let expected = rows + .expressions + .iter() + .find(|row| row.id == *id) + .filter(|row| !row.key.is_empty()) + .and_then(|row| { + self.output_schema_type( + content, + &row.key, + rows.transform_attributes.output_path.as_deref(), + ) + }); + Some(CursorScope::value(scope, expected)) + } + (DecisionNodeKind::SwitchNode { .. }, CursorTarget::Expression { .. }) => { + Some(CursorScope::condition(input_scope())) + } + (DecisionNodeKind::DecisionTableNode { content: table }, _) => { + self.graph_table_scope(content, table, node_analysis, cursor, cache) + } + _ => None, + } + } + + fn graph_table_scope( + &self, + content: &GraphContent, + table: &DecisionTableContent, + node_analysis: &GraphNodeAnalysis, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let scope = GraphAnalyzer::scope_with_nodes( + &node_analysis.handler_input, + &node_analysis.nodes_scope, + ); + match &cursor.target { + CursorTarget::DecisionTableHead { col } => { + let known = table.inputs.iter().any(|c| c.id == *col) + || table.outputs.iter().any(|c| c.id == *col); + known.then(|| CursorScope::path(scope)) + } + CursorTarget::DecisionTableCell { col, .. } => { + let is = self.graph_intellisense(); + 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) => { + let field_type = Self::return_type(&is, field, &scope); + CursorScope::unary(scope.with_dollar(&field_type)) + } + None => CursorScope::condition(scope), + }); + } + let column = table.outputs.iter().find(|c| c.id == *col)?; + let dictionaries = self.graph_dictionary_types(&content.imports); + let expected = GraphAnalyzer::output_expected(table, col, &dictionaries) + .or_else(|| { + (!column.field.is_empty()) + .then(|| { + self.output_schema_type( + content, + column.field.strip_suffix("[]").unwrap_or(&column.field), + table.transform_attributes.output_path.as_deref(), + ) + .map(|t| { + CursorScope::collected_type(t, column.field.ends_with("[]")) + }) + }) + .flatten() + }) + .or_else(|| { + cache.infer(cursor, || { + table + .rules + .iter() + .enumerate() + .map(|(index, rule)| { + let id = GraphAnalyzer::row_key(rule, index); + let t = rule + .get(col) + .filter(|cell| !cell.is_empty()) + .map(|cell| Self::return_type(&is, cell, &scope)); + (id, t) + }) + .collect() + }) + }); + Some(CursorScope::value(scope, expected)) + } + _ => None, + } + } + + fn output_schema_type( + &self, + content: &GraphContent, + key: &str, + output_path: Option<&str>, + ) -> Option { + let schema = content.nodes.iter().find_map(|node| match &node.kind { + DecisionNodeKind::OutputNode { content } => content.schema.as_ref(), + _ => None, + })?; + let dictionaries = self.graph_dictionary_types(&content.imports); + let output = SchemaType::hint_type_with(schema, &dictionaries); + let key = match output_path.filter(|p| !p.is_empty()) { + Some(path) => format!("{path}.{key}"), + None => key.to_string(), + }; + CursorScope::known_type(output.resolve_at(&key)) + } + + fn return_type( + is: &SharedIntelliSense, + source: &Arc, + scope: &VariableType, + ) -> VariableType { + IntelliSenseSource::analyze( + &mut is.borrow_mut(), + source, + ExpressionKind::Standard, + scope, + ) + .return_type + .shallow_clone() + } +} diff --git a/core/engine/src/workspace/slot/siblings.rs b/core/engine/src/workspace/slot/siblings.rs index 6d5ac618..c4c77125 100644 --- a/core/engine/src/workspace/slot/siblings.rs +++ b/core/engine/src/workspace/slot/siblings.rs @@ -3,12 +3,14 @@ use std::sync::Arc; use ahash::HashMap; use zen_expression::variable::VariableType; -use super::literal_union; +use super::CursorScope; use crate::workspace::types::{Cursor, CursorTarget}; +type ColumnKey = (Arc, Arc, Arc); + #[derive(Default)] pub(super) struct SiblingCache { - columns: HashMap<(Arc, Arc, Arc), ColumnTypes>, + columns: HashMap, } struct ColumnTypes { @@ -25,7 +27,6 @@ fn merge(a: Option, b: Option<&VariableType>) -> Option>(), - expected - ); - } - assert_eq!(calls, 1); + .and_then(CursorScope::literal_union) } } diff --git a/core/engine/src/workspace/slot/utf16.rs b/core/engine/src/workspace/slot/utf16.rs deleted file mode 100644 index 173e2eff..00000000 --- a/core/engine/src/workspace/slot/utf16.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::workspace::types::Span; - -pub(crate) fn utf16_to_byte(text: &str, pos: u32) -> usize { - let mut units = 0u32; - for (byte, ch) in text.char_indices() { - let len = ch.len_utf16() as u32; - if pos < units + len { - return byte; - } - units += len; - } - text.len() -} - -pub(crate) fn byte_to_utf16(text: &str, byte: usize) -> u32 { - let mut end = byte.min(text.len()); - while !text.is_char_boundary(end) { - end -= 1; - } - text[..end].encode_utf16().count() as u32 -} - -pub(crate) fn utf16_span(text: &str, span: Span) -> Span { - ( - byte_to_utf16(text, span.0 as usize), - byte_to_utf16(text, span.1 as usize), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ascii_is_identity() { - assert_eq!(utf16_to_byte("abc", 2), 2); - assert_eq!(byte_to_utf16("abc", 2), 2); - assert_eq!(utf16_span("abc", (1, 3)), (1, 3)); - } - - #[test] - fn multibyte_and_surrogate_pairs_round_trip() { - let text = "é😀x"; - assert_eq!(utf16_to_byte(text, 1), 2); - assert_eq!(utf16_to_byte(text, 3), 6); - assert_eq!(byte_to_utf16(text, 6), 3); - assert_eq!(utf16_span(text, (2, 6)), (1, 3)); - assert_eq!(utf16_to_byte(text, 2), 2); - assert_eq!(utf16_to_byte(text, 99), text.len()); - assert_eq!(byte_to_utf16(text, 99), 4); - } -} diff --git a/core/engine/src/workspace/types/diagnostic.rs b/core/engine/src/workspace/types/diagnostic.rs index 4ecdb1a9..923b933e 100644 --- a/core/engine/src/workspace/types/diagnostic.rs +++ b/core/engine/src/workspace/types/diagnostic.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::sync::Arc; use serde::Serialize; @@ -15,7 +14,30 @@ impl SpanOps { } pub(crate) fn char_span(source: &str, span: Span) -> Span { - crate::workspace::slot::utf16::utf16_span(source, span) + ( + Self::char_offset(source, span.0 as usize), + Self::char_offset(source, span.1 as usize), + ) + } + + pub(crate) fn byte_offset(source: &str, pos: u32) -> usize { + let mut units = 0u32; + for (byte, ch) in source.char_indices() { + let len = ch.len_utf16() as u32; + if pos < units + len { + return byte; + } + units += len; + } + source.len() + } + + fn char_offset(source: &str, byte: usize) -> u32 { + let mut end = byte.min(source.len()); + while !source.is_char_boundary(end) { + end -= 1; + } + Self::char_len(&source[..end]) } pub(crate) fn replace_at_char_spans(source: &str, spans: &[Span], new_text: &str) -> String { @@ -33,8 +55,8 @@ impl SpanOps { let mut out = source.to_string(); for (char_start, char_end) in sorted.into_iter().rev() { - let byte_start = crate::workspace::slot::utf16::utf16_to_byte(&out, char_start); - let byte_end = crate::workspace::slot::utf16::utf16_to_byte(&out, char_end); + let byte_start = Self::byte_offset(&out, char_start); + let byte_end = Self::byte_offset(&out, char_end); out.replace_range(byte_start..byte_end, new_text); } out @@ -50,11 +72,11 @@ pub struct Diagnostic { pub location: DiagnosticLocation, #[serde(skip_serializing_if = "Option::is_none")] pub expr_code: Option<&'static str>, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(skip_serializing_if = "DiagnosticArgs::is_empty")] pub args: DiagnosticArgs, } -pub type DiagnosticArgs = BTreeMap<&'static str, String>; +pub use zen_expression::intellisense::diagnostic::DiagnosticArgs; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/core/engine/tests/data/policy/fixtures/slots.json b/core/engine/tests/data/policy/fixtures/slots.json new file mode 100644 index 00000000..d9996d20 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots.json @@ -0,0 +1,271 @@ +{ + "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": "customer", + "properties": [ + { + "id": "p1", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "tier", + "type": "string", + "enum": [ + "gold", + "silver" + ], + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "status", + "type": "relationship", + "target": "status", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "since", + "type": "date", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dt", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "inputs": [ + { + "id": "in_declared", + "name": "Age", + "field": "customer.age" + }, + { + "id": "in_dict", + "name": "Status", + "field": "customer.status" + }, + { + "id": "in_since", + "name": "Since", + "field": "customer.since" + }, + { + "id": "in_since_date", + "name": "Since date", + "field": "d(customer.since)" + }, + { + "id": "in_computed", + "name": "Next age", + "field": "customer.age + 1" + }, + { + "id": "in_condition", + "name": "Condition" + } + ], + "outputs": [ + { + "id": "out_dict", + "name": "Level", + "field": "customer.level", + "type": "status" + }, + { + "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" + } + ], + "rules": [ + { + "_id": "r1", + "in_declared": "> 18", + "in_dict": "\"open\"", + "in_since": "", + "in_computed": "> 19", + "in_condition": "customer.age > 1", + "out_dict": "\"open\"", + "out_number": "1", + "out_declared": "\"gold\"", + "out_union": "\"a\"", + "out_untyped": "customer.name", + "in_since_date": "" + }, + { + "_id": "r2", + "in_declared": "", + "in_dict": "", + "in_since": "", + "in_computed": "", + "in_condition": "", + "out_dict": "\"closed\"", + "out_number": "2", + "out_declared": "\"silver\"", + "out_union": "\"b\"", + "out_untyped": "customer.name", + "in_since_date": "" + }, + { + "_id": "r3", + "in_declared": "", + "in_dict": "", + "in_since": "", + "in_computed": "", + "in_condition": "", + "out_dict": "", + "out_number": "", + "out_declared": "", + "out_union": "", + "out_untyped": "", + "in_since_date": "" + } + ] + } + }, + "children": [] + }, + { + "id": "ex_declared", + "type": "expression", + "props": { + "data": { + "key": "customer.tier", + "value": "\"gold\"" + } + } + }, + { + "id": "ex_computed", + "type": "expression", + "props": { + "data": { + "key": "customer.total", + "value": "customer.age * 2" + } + } + }, + { + "id": "as1", + "type": "assertion", + "props": { + "data": { + "output": "customer.isAdult", + "conditions": [ + { + "id": "c1", + "expression": "customer.age >= 18", + "operator": "and", + "depth": 0 + } + ] + } + }, + "children": [] + }, + { + "id": "m_declared", + "type": "match", + "props": { + "data": { + "key": "customer.status", + "arms": [ + { + "id": "a1", + "condition": "customer.age > 50", + "value": "\"open\"" + }, + { + "id": "a2", + "condition": "", + "value": "\"closed\"" + } + ] + } + } + }, + { + "id": "m_union", + "type": "match", + "props": { + "data": { + "key": "customer.group", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 50", + "value": "\"senior\"" + }, + { + "id": "b2", + "condition": "", + "value": "\"kid\"" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/fixtures/slots_graph.json b/core/engine/tests/data/policy/fixtures/slots_graph.json new file mode 100644 index 00000000..05782bf0 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots_graph.json @@ -0,0 +1,216 @@ +{ + "imports": [ + "slots_graph_dicts.json" + ], + "nodes": [ + { + "id": "in", + "name": "in", + "type": "inputNode", + "content": { + "schema": "{\"type\": \"object\", \"properties\": {\"age\": {\"type\": \"number\"}, \"name\": {\"type\": \"string\"}}, \"required\": [\"age\", \"name\"]}" + } + }, + { + "id": "calc", + "name": "calc", + "type": "expressionNode", + "content": { + "passThrough": true, + "expressions": [ + { + "id": "row_total", + "key": "total", + "value": "age * 2" + }, + { + "id": "row_vip", + "key": "vip", + "value": "age > 60" + }, + { + "id": "row_extra", + "key": "extra", + "value": "name" + } + ] + } + }, + { + "id": "sw", + "name": "sw", + "type": "switchNode", + "content": { + "statements": [ + { + "id": "s1", + "condition": "age > 18" + } + ] + } + }, + { + "id": "dt", + "name": "dt", + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "c_field", + "name": "Age", + "field": "age" + }, + { + "id": "c_condition", + "name": "Condition" + } + ], + "outputs": [ + { + "id": "o_dict", + "name": "Tier", + "field": "tier", + "type": "customerTier" + }, + { + "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_condition": "age > 1", + "o_dict": "'VIP'", + "o_plain": "1", + "o_schema": "", + "o_schema_dict": "" + }, + { + "_id": "r2", + "c_field": "", + "c_condition": "", + "o_dict": "'STD'", + "o_plain": "2", + "o_schema": "", + "o_schema_dict": "" + } + ] + } + }, + { + "id": "dt_untyped", + "name": "dt_untyped", + "type": "decisionTableNode", + "content": { + "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": "" + } + ] + } + }, + { + "id": "out", + "name": "out", + "type": "outputNode", + "content": { + "schema": "{\"type\": \"object\", \"properties\": {\"total\": {\"type\": \"number\"}, \"vip\": {\"type\": \"boolean\"}, \"level\": {\"$dictionary\": \"customerTier\"}}, \"required\": [\"total\", \"vip\", \"level\"]}" + } + } + ], + "edges": [ + { + "id": "e1", + "sourceId": "in", + "targetId": "calc", + "sourceHandle": null + }, + { + "id": "e2", + "sourceId": "calc", + "targetId": "sw", + "sourceHandle": null + }, + { + "id": "e3", + "sourceId": "sw", + "targetId": "dt", + "sourceHandle": null + }, + { + "id": "e4", + "sourceId": "dt", + "targetId": "out", + "sourceHandle": null + }, + { + "id": "e5", + "sourceId": "sw", + "targetId": "dt_untyped", + "sourceHandle": null + }, + { + "id": "e6", + "sourceId": "dt_untyped", + "targetId": "out", + "sourceHandle": null + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json b/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json new file mode 100644 index 00000000..c000c59c --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json @@ -0,0 +1,26 @@ +{ + "imports": [], + "blocks": [ + { + "id": "dict1", + "type": "dictionary", + "props": { + "data": { + "name": "customerTier", + "entries": [ + { + "id": "e0", + "value": "VIP", + "label": "Very important" + }, + { + "id": "e1", + "value": "STD", + "label": "Standard" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/slots.toml b/core/engine/tests/data/policy/slots.toml new file mode 100644 index 00000000..8ed00c8a --- /dev/null +++ b/core/engine/tests/data/policy/slots.toml @@ -0,0 +1,383 @@ +# Cursor scope per cell: kind, role, expected type and unary subject. +# Optional `text` (with `|` caret) checks the slot the editor gets there. + +policies = ["slots.json"] +graphs = ["slots_graph_dicts.json", "slots_graph.json"] + +# Policy decision table + +[[test]] +name = "input cell on a declared field is unary over the field" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_declared" +row = "r1" +kind = "unary" +role = "unary" +subject = "number" + +[[test]] +name = "input cell on a dictionary field offers labelled values" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_dict" +row = "r2" +kind = "unary" +subject = "status" +text = "|" +state = "unaryStart" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "input cell on a declared date field is a string, so no calendar" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_since" +row = "r1" +kind = "unary" +expected = "" +subject = "string" + +[[test]] +name = "input cell on d(field) is a real date" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_since_date" +row = "r1" +kind = "unary" +subject = "date" + +[[test]] +name = "input cell on a computed field is unary over its result" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_computed" +row = "r1" +kind = "unary" +subject = "number" + +[[test]] +name = "input column without a field is a condition" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_condition" +row = "r1" +kind = "standard" +role = "condition" +expected = "bool" + +[[test]] +name = "output cell with a dictionary type offers labelled values" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_dict" +row = "r3" +role = "value" +expected = "status" +text = "|" +state = "value" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "output cell with a declared type" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_number" +row = "r3" +role = "value" +expected = "number" + +[[test]] +name = "output cell on a declared field expects the field type" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_declared" +row = "r3" +role = "value" +expected = '"gold" | "silver"' + +[[test]] +name = "undeclared output cell uses the sibling literal union" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_union" +row = "r3" +role = "value" +expected = '"a" | "b"' + +[[test]] +name = "policy tables keep the written union for every row" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_union" +row = "r1" +expected = '"a" | "b"' + +[[test]] +name = "output cell with non-literal siblings has no expectation" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_untyped" +row = "r3" +role = "value" +expected = "" + +[[test]] +name = "column head is a path" +policy = "slots.json" +block_id = "dt" +target = "head" +id = "in_computed" +kind = "standard" +role = "path" + +# Policy expression, assertion and match blocks + +[[test]] +name = "expression value expects the declared key type" +policy = "slots.json" +block_id = "ex_declared" +target = "expression" +id = "ex_declared" +role = "value" +expected = '"gold" | "silver"' + +[[test]] +name = "expression value with an undeclared key has no expectation" +policy = "slots.json" +block_id = "ex_computed" +target = "expression" +id = "ex_computed" +role = "value" +expected = "" + +[[test]] +name = "expression key is a path" +policy = "slots.json" +block_id = "ex_declared" +target = "expression_key" +role = "path" + +[[test]] +name = "assertion condition" +policy = "slots.json" +block_id = "as1" +target = "expression" +id = "c1" +role = "condition" +expected = "bool" + +[[test]] +name = "assertion output is a path" +policy = "slots.json" +block_id = "as1" +target = "assertion_output" +role = "path" + +[[test]] +name = "match target is a path" +policy = "slots.json" +block_id = "m_declared" +target = "match_target" +role = "path" + +[[test]] +name = "match arm condition" +policy = "slots.json" +block_id = "m_declared" +target = "expression" +id = "a1" +role = "condition" +expected = "bool" + +[[test]] +name = "match value on a declared key offers labelled values" +policy = "slots.json" +block_id = "m_declared" +target = "match_value" +id = "a1" +role = "value" +expected = "status" +text = "|" +state = "value" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "match value on an undeclared key ignores sibling arms" +policy = "slots.json" +block_id = "m_union" +target = "match_value" +id = "b2" +role = "value" +expected = "" + +[[test]] +name = "data model has no scope" +policy = "slots.json" +block_id = "dm" +target = "data_model_name" + +[[test]] +name = "unknown block has no scope" +policy = "slots.json" +block_id = "missing" +target = "expression" +id = "x" + +# Graph + +[[test]] +name = "graph expression row expects the output schema type" +policy = "slots_graph.json" +block_id = "calc" +target = "expression" +id = "row_total" +role = "value" +expected = "number" + +[[test]] +name = "graph expression row outside the schema has no expectation" +policy = "slots_graph.json" +block_id = "calc" +target = "expression" +id = "row_extra" +role = "value" +expected = "" + +[[test]] +name = "graph expression key is a path" +policy = "slots_graph.json" +block_id = "calc" +target = "expression_key" +role = "path" + +[[test]] +name = "graph transform input is a path" +policy = "slots_graph.json" +block_id = "calc" +target = "transform_input" +role = "path" + +[[test]] +name = "switch node has no transform input" +policy = "slots_graph.json" +block_id = "sw" +target = "transform_input" + +[[test]] +name = "graph switch statement is a condition" +policy = "slots_graph.json" +block_id = "sw" +target = "expression" +id = "s1" +role = "condition" +expected = "bool" + +[[test]] +name = "graph table input cell is unary over the field" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "c_field" +row = "r1" +kind = "unary" +subject = "number" + +[[test]] +name = "graph table condition column" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "c_condition" +row = "r1" +role = "condition" + +[[test]] +name = "graph output cell with a dictionary type offers labelled values" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_dict" +row = "r1" +role = "value" +expected = "customerTier" +text = "|" +state = "value" +options = ["VIP=Very important", "STD=Standard"] + +[[test]] +name = "graph output cell falls back to the output schema" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_schema" +row = "r1" +expected = "number" + +[[test]] +name = "graph output schema dictionary offers labelled values" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_schema_dict" +row = "r1" +expected = "customerTier" +text = "|" +state = "value" +options = ["VIP=Very important", "STD=Standard"] + +[[test]] +name = "graph undeclared bool column uses sibling literals" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_bool" +row = "u3" +expected = "bool" + +[[test]] +name = "graph undeclared string column uses the sibling union" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_str" +row = "u3" +expected = '"a" | "b"' + +[[test]] +name = "graph sibling union excludes the edited row" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_str" +row = "u1" +expected = '"b"' + +[[test]] +name = "graph mixed sibling literals have no expectation" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_mixed" +row = "u3" +expected = "" + +[[test]] +name = "graph column head is a path" +policy = "slots_graph.json" +block_id = "dt" +target = "head" +id = "o_dict" +role = "path" diff --git a/core/engine/tests/engine.rs b/core/engine/tests/engine.rs index 6f49a99e..d526e0e7 100644 --- a/core/engine/tests/engine.rs +++ b/core/engine/tests/engine.rs @@ -300,7 +300,6 @@ async fn engine_graph_tests() { .unwrap() .result; - // Default-feature JSON serialization rounds decimals through f64. let expected = serde_json::to_value(&test_case.output).expect("serializable output"); let actual = serde_json::to_value(&result).expect("serializable result"); assert!( @@ -353,7 +352,6 @@ fn json_approx_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool { } } -// Stored snapshots contain full-precision decimals. #[cfg(feature = "arbitrary_precision")] #[tokio::test] #[cfg_attr(miri, ignore)] diff --git a/core/engine/tests/input_node_null.rs b/core/engine/tests/input_node_null.rs deleted file mode 100644 index 2204551f..00000000 --- a/core/engine/tests/input_node_null.rs +++ /dev/null @@ -1,87 +0,0 @@ -use serde_json::json; -use std::sync::Arc; -use zen_engine::Decision; - -fn graph(required: &[&str]) -> Decision { - let schema = json!({ - "type": "object", - "properties": { - "aircraft": { - "type": "object", - "properties": { "apuFault": { "type": "boolean" }, "tail": { "type": "string" } }, - "required": required - } - }, - "required": ["aircraft"] - }); - graph_with_schema(schema) -} - -fn graph_with_schema(schema: serde_json::Value) -> Decision { - let content = serde_json::from_value(json!({ - "nodes": [ - { - "id": "in", "name": "in", "type": "inputNode", - "content": { "schema": schema.to_string() } - }, - { "id": "out", "name": "out", "type": "outputNode", "content": {} } - ], - "edges": [{ "id": "e1", "sourceId": "in", "targetId": "out" }] - })) - .unwrap(); - Decision::from(Arc::new(content)) -} - -#[tokio::test] -async fn null_on_optional_property_reads_as_absent() { - let decision = graph(&[]); - let result = decision - .evaluate(json!({ "aircraft": { "apuFault": null, "tail": "G-EZAA" } }).into()) - .await - .unwrap(); - assert_eq!( - result.result, - json!({ "aircraft": { "apuFault": null, "tail": "G-EZAA" } }).into() - ); -} - -#[tokio::test] -async fn null_on_required_property_still_fails() { - let decision = graph(&["apuFault"]); - let error = decision - .evaluate(json!({ "aircraft": { "apuFault": null } }).into()) - .await; - assert!(error.is_err()); -} - -#[tokio::test] -async fn wrong_type_on_optional_property_still_fails() { - let decision = graph(&[]); - let error = decision - .evaluate(json!({ "aircraft": { "apuFault": "yes" } }).into()) - .await; - assert!(error.is_err()); -} - -#[tokio::test] -async fn schema_valid_nulls_are_not_removed_before_validation() { - for constraints in [ - json!({"minProperties": 1}), - json!({"anyOf": [{"required": ["value"]}, {"required": ["other"]}]}), - ] { - let mut schema = json!({ - "type": "object", - "properties": {"value": {"type": ["boolean", "null"]}} - }); - schema - .as_object_mut() - .unwrap() - .extend(constraints.as_object().unwrap().clone()); - let decision = graph_with_schema(json!({ - "type": "object", "properties": {"payload": schema}, "required": ["payload"] - })); - let input = json!({"payload": {"value": null}}); - let result = decision.evaluate(input.clone().into()).await.unwrap(); - assert_eq!(result.result, input.into()); - } -} diff --git a/core/engine/tests/policy.rs b/core/engine/tests/policy.rs index 2ac3d756..d22203fb 100644 --- a/core/engine/tests/policy.rs +++ b/core/engine/tests/policy.rs @@ -384,52 +384,6 @@ 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(); @@ -4962,37 +4916,3 @@ 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_dates.rs b/core/engine/tests/policy_dates.rs deleted file mode 100644 index 7e81dcd4..00000000 --- a/core/engine/tests/policy_dates.rs +++ /dev/null @@ -1,242 +0,0 @@ -use serde_json::json; -use zen_engine::policy::{Cursor, CursorTarget, EvaluateRequest, PolicyWorkspace}; - -fn workspace(source: &str) -> PolicyWorkspace { - let mut ws = PolicyWorkspace::new(); - ws.set_policy("dates", serde_json::from_value(json!({"blocks":[ - {"id":"customer", "type":"dataModel", "props":{"data":{"name":"customer", "properties":[ - {"id":"since", "name":"since", "type":"date", "array":false, "optional":false}, - {"id":"until", "name":"until", "type":"date", "array":false, "optional":false}, - {"id":"dates", "name":"dates", "type":"date", "array":true, "optional":false}, - {"id":"maybe", "name":"maybe", "type":"date", "array":false, "optional":true}, - {"id":"contact", "name":"contact", "type":"relationship", "target":"contact", "array":false, "optional":false} - ]}}}, - {"id":"contact", "type":"dataModel", "props":{"data":{"name":"contact", "properties":[ - {"id":"birthday", "name":"birthday", "type":"date", "array":false, "optional":false} - ]}}}, - {"id":"globals", "type":"dataModel", "props":{"data":{"scope":"global", "name":"", "properties":[ - {"id":"asOf", "name":"asOf", "type":"date", "array":false, "optional":false} - ]}}}, - {"id":"e", "type":"expression", "props":{"data":{"key":"result", "value":source}}} - ]})).unwrap()); - ws -} - -#[test] -fn declared_dates_preserve_existing_input_and_output_representation() { - for (source, expected) in [ - ("d(customer.since).year()", json!(2024)), - ("d(customer.dates[0]).year()", json!(2024)), - ("d(customer.contact.birthday).year()", json!(2000)), - ("d(asOf).year()", json!(2025)), - ("customer.maybe == null", json!(true)), - ] { - let ws = workspace(source); - let input = json!({"asOf":"2025-06-01", "customer":{ - "since":"2024-01-01", "until":"2026-01-01", "dates":["2024-03-01", null], - "maybe":null, "contact":{"birthday":"2000-02-01"}, "untyped":"2024-01-01" - }}); - let req = EvaluateRequest { - policy_path: "dates".into(), - input: input.clone().into(), - goals: vec![], - trace: true, - }; - let result = ws.evaluate(&req).unwrap(); - let output = serde_json::to_value(&result.output).unwrap(); - assert_eq!(output["result"], expected, "{source}"); - assert_eq!(output["customer"], input["customer"]); - assert_eq!(output["asOf"], input["asOf"]); - assert_eq!(serde_json::to_value(&req.input).unwrap(), input); - } -} - -#[test] -fn declared_dates_keep_existing_string_validation() { - for value in ["UTC", "Japan", "2024-99-99", "not a date"] { - let result = workspace("asOf") - .evaluate(&EvaluateRequest { - policy_path: "dates".into(), - input: json!({"asOf":value}).into(), - goals: vec![], - trace: false, - }) - .unwrap(); - assert_eq!( - serde_json::to_value(result.output).unwrap()["result"], - value - ); - } -} - -#[test] -fn raw_date_strings_report_unsupported_methods_and_ordering() { - for source in [ - "customer.since.year()", - "customer.dates[0].year()", - "customer.contact.birthday.year()", - "asOf.year()", - "map(customer.dates as item, item.year())", - "customer.since < customer.until", - "asOf > customer.since", - ] { - let ws = workspace(source); - let diagnostics = ws.diagnostics("dates"); - assert!( - diagnostics - .iter() - .any(|d| d.location.block_id.as_deref() == Some("e")), - "{source}: {diagnostics:?}" - ); - if source.contains(".year()") { - assert!( - diagnostics.iter().any(|d| d.message.contains("Use d(...)")), - "{source}: {diagnostics:?}" - ); - } - } -} - -#[test] -fn date_method_completions_require_an_explicit_date_value() { - for (source, has_year) in [ - ("customer.since.", false), - ("customer.dates[0].", false), - ("customer.contact.birthday.", false), - ("asOf.", false), - ("d(customer.since).", true), - ("d(asOf).", true), - ] { - let ws = workspace(source); - let items = ws.completions(&Cursor { - policy_path: "dates".into(), - block_id: "e".into(), - target: CursorTarget::Expression { id: "e".into() }, - pos: source.encode_utf16().count() as u32, - }); - assert_eq!( - items.iter().any(|item| item.label == "year"), - has_year, - "{source}" - ); - } -} - -#[test] -fn supported_date_string_comparisons_keep_clean_diagnostics_and_results() { - for (source, expected) in [ - ("d(customer.since).year()", json!(2024)), - ("d(customer.since) < d(customer.until)", json!(true)), - ("customer.since < d(customer.until)", json!(true)), - ("d(customer.until) > customer.since", json!(true)), - ("customer.since == d(customer.since)", json!(true)), - ( - "customer.since in [d(customer.since), d(customer.until)]", - json!(true), - ), - ( - "customer.since in [d(customer.since)..d(customer.until)]", - json!(true), - ), - ] { - let ws = workspace(source); - let diagnostics = ws.diagnostics("dates"); - assert!( - diagnostics - .iter() - .all(|d| d.location.block_id.as_deref() != Some("e")), - "{source}: {diagnostics:?}" - ); - let result = ws - .evaluate(&EvaluateRequest { - policy_path: "dates".into(), - input: json!({"customer":{"since":"2024-01-01", "until":"2026-01-01"}}).into(), - goals: vec![], - trace: false, - }) - .unwrap(); - assert_eq!( - serde_json::to_value(result.output).unwrap()["result"], - expected, - "{source}" - ); - } -} - -#[test] -fn evaluation_errors_do_not_poison_the_workspace() { - let ws = workspace("d(customer.since).year()"); - for input in [json!(123), json!({}), json!([])] { - let req = |since| EvaluateRequest { - policy_path: "dates".into(), - input: json!({"customer":{"since":since}}).into(), - goals: vec![], - trace: false, - }; - assert!(ws.evaluate(&req(input)).is_err()); - let result = ws.evaluate(&req(json!("2024-01-01"))).unwrap(); - assert_eq!( - serde_json::to_value(result.output).unwrap()["result"], - json!(2024) - ); - } -} - -#[test] -fn runtime_expression_errors_do_not_poison_the_workspace() { - let ws = - workspace("customer.since == \"bad\" ? customer.since.year() : d(customer.since).year()"); - let req = |since| EvaluateRequest { - policy_path: "dates".into(), - input: json!({"customer":{"since":since}}).into(), - goals: vec![], - trace: false, - }; - for _ in 0..3 { - // This string passes input validation and fails inside the expression VM. - assert!(ws.evaluate(&req("bad")).is_err()); - let result = ws.evaluate(&req("2024-01-01")).unwrap(); - assert_eq!( - serde_json::to_value(result.output).unwrap()["result"], - json!(2024) - ); - } -} - -#[test] -fn global_date_columns_keep_calendar_hints_without_changing_dollar_type() { - use zen_expression::variable::VariableType; - let mut ws = PolicyWorkspace::new(); - ws.set_policy("p", serde_json::from_value(json!({"blocks":[ - {"id":"globals","type":"dataModel","props":{"data":{"scope":"global","name":"","properties":[ - {"id":"asOf","name":"asOf","type":"date","array":false,"optional":false} - ]}}}, - {"id":"dt","type":"decisionTable","props":{"data":{"hitPolicy":"first", - "inputs":[{"id":"i","name":"As of","field":"asOf"}], - "outputs":[{"id":"o","name":"Result","field":"result"}], - "rules":[{"_id":"r","i":"> d(\"2024-01-01\")","o":"true"}] - }}} - ]})).unwrap()); - let mut cursor = Cursor { - policy_path: "p".into(), - block_id: "dt".into(), - pos: 2, - target: CursorTarget::DecisionTableCell { - row: "r".into(), - col: "i".into(), - }, - }; - assert_eq!( - ws.slot(&cursor, "> ").unwrap().slot.expected, - Some(VariableType::Date) - ); - assert_eq!( - ws.slot(&cursor, "$.").unwrap().slot.operand, - Some(VariableType::String) - ); - cursor.pos = 5; - assert_eq!( - ws.slot(&cursor, "d($).").unwrap().slot.operand, - Some(VariableType::Date) - ); -} diff --git a/core/engine/tests/policy_import_scope.rs b/core/engine/tests/policy_import_scope.rs deleted file mode 100644 index 00e81699..00000000 --- a/core/engine/tests/policy_import_scope.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::sync::Arc; - -use serde_json::{json, Value}; -use zen_engine::loader::MemoryLoader; -use zen_engine::policy::{EvaluateRequest, PolicyWorkspace, ScopeRequest, Severity}; -use zen_engine::DecisionEngine; - -fn expression(key: &str, value: &str) -> Value { - json!({"id": key, "type": "expression", "props": {"data": {"key": key, "value": value}}}) -} - -fn dictionary(name: &str, value: &str) -> Value { - json!({"id": name, "type": "dictionary", "props": {"data": { - "name": name, "entries": [{"value": value, "label": value}] - }}}) -} - -fn policy(imports: &[&str], blocks: Vec) -> Value { - json!({"contentType": "policy", "imports": imports, "blocks": blocks}) -} - -fn siblings(duplicate: bool) -> Vec<(&'static str, Value)> { - vec![ - ("A", policy(&[], vec![expression("base", "10")])), - ( - "B", - policy( - &["A"], - vec![ - dictionary("choice", "approve"), - expression("decision", "\"approve\""), - ], - ), - ), - ( - "C", - policy( - &["A"], - vec![ - dictionary(if duplicate { "choice" } else { "otherChoice" }, "decline"), - expression(if duplicate { "decision" } else { "other" }, "\"decline\""), - ], - ), - ), - ] -} - -fn workspace(docs: &[(&str, Value)]) -> PolicyWorkspace { - let mut ws = PolicyWorkspace::new(); - for (path, doc) in docs { - ws.set_policy(*path, serde_json::from_value(doc.clone()).unwrap()); - } - ws -} - -fn request(path: &str, input: Value) -> EvaluateRequest { - EvaluateRequest { - policy_path: Arc::from(path), - input: input.into(), - goals: vec![], - trace: true, - } -} - -fn output(ws: &PolicyWorkspace, path: &str) -> Value { - serde_json::to_value(ws.evaluate(&request(path, json!({}))).unwrap().output).unwrap() -} - -#[test] -fn workspace_and_trace_only_execute_outgoing_imports() { - let ws = workspace(&siblings(false)); - for (path, expected, count) in [ - ("A", json!({"base":10}), 1), - ("B", json!({"base":10,"decision":"approve"}), 2), - ("C", json!({"base":10,"other":"decline"}), 2), - ] { - assert_eq!(output(&ws, path), expected); - let enhanced = ws.enhance_trace(&request(path, json!({}))).unwrap(); - assert_eq!(serde_json::to_value(enhanced.output).unwrap(), expected); - assert_eq!(enhanced.trace.unwrap().executions.len(), count); - } -} - -#[tokio::test] -async fn compiled_and_lazy_engines_agree_with_sibling_duplicate_names() { - for duplicate in [false, true] { - let docs = siblings(duplicate); - for precompile in [false, true] { - let loader = Arc::new(MemoryLoader::default()); - for (path, doc) in &docs { - loader.add( - *path, - serde_json::from_value::(doc.clone()) - .unwrap(), - ); - } - let engine = DecisionEngine::default().with_loader(loader); - if precompile { - assert!(engine.compile().is_empty()); - } - // Switching entries repeatedly must never reuse another entry's scope. - for path in ["B", "C", "A", "C", "B"] { - let result = engine.evaluate(path, json!({}).into()).await.unwrap(); - let result = serde_json::to_value(result.result).unwrap(); - let expected = match path { - "B" => json!({"base":10,"decision":"approve"}), - "C" if duplicate => json!({"base":10,"decision":"decline"}), - "C" => json!({"base":10,"other":"decline"}), - _ => json!({"base":10}), - }; - assert_eq!(result, expected, "precompile={precompile}, entry={path}"); - } - } - } -} - -#[test] -fn sibling_dictionaries_and_diagnostics_stay_separate() { - let ws = workspace(&siblings(true)); - for (path, values) in [ - ("A", vec![]), - ("B", vec!["approve"]), - ("C", vec!["decline"]), - ] { - let errors: Vec<_> = ws - .diagnostics(path) - .into_iter() - .filter(|d| d.severity == Severity::Error) - .collect(); - assert!(errors.is_empty(), "{path}: {errors:?}"); - let dictionaries = ws.dictionaries(&ScopeRequest::for_policy(path)); - let actual: Vec<_> = dictionaries - .iter() - .flat_map(|d| d.entries.iter().map(|e| e.value.as_ref())) - .collect(); - assert_eq!(actual, values); - } -} - -#[tokio::test] -async fn explicitly_composing_conflicting_siblings_reports_errors_at_the_entry() { - let mut docs = siblings(true); - docs.push(("D", policy(&["B", "C"], vec![]))); - let ws = workspace(&docs); - let diagnostics = ws.diagnostics("D"); - for code in ["DuplicateWriter", "DataModelCollision"] { - assert!( - diagnostics.iter().any(|d| format!("{:?}", d.code) == code), - "{diagnostics:?}" - ); - } - assert!(diagnostics - .iter() - .all(|d| d.location.policy_path.as_ref() == "D")); - for precompile in [false, true] { - let loader = Arc::new(MemoryLoader::default()); - for (path, doc) in &docs { - loader.add( - *path, - serde_json::from_value::(doc.clone()).unwrap(), - ); - } - let engine = DecisionEngine::default().with_loader(loader); - if precompile { - let failures = engine.compile(); - assert_eq!(failures.len(), 1); - assert_eq!(failures[0].key.as_ref(), "D"); - } - assert!(engine.evaluate("D", json!({}).into()).await.is_err()); - assert!(engine.evaluate("B", json!({}).into()).await.is_ok()); - assert!(engine.evaluate("C", json!({}).into()).await.is_ok()); - } -} - -#[test] -fn diamond_imports_execute_the_shared_dependency_once() { - let mut docs = siblings(false); - docs.push(( - "D", - policy(&["B", "C"], vec![expression("total", "base + 5")]), - )); - let ws = workspace(&docs); - let result = ws.enhance_trace(&request("D", json!({}))).unwrap(); - assert_eq!( - serde_json::to_value(result.output).unwrap(), - json!({"base":10,"decision":"approve","other":"decline","total":15}) - ); - let trace = result.trace.unwrap(); - assert_eq!(trace.executions.len(), 4); - assert_eq!( - trace - .executions - .iter() - .filter(|e| e.block_id.as_ref() == "base") - .count(), - 1 - ); -} - -#[test] -fn scope_updates_after_import_and_sibling_edits() { - let mut ws = workspace(&siblings(false)); - assert_eq!(output(&ws, "B"), json!({"base":10,"decision":"approve"})); - ws.set_policy( - "C", - serde_json::from_value(policy(&["A"], vec![expression("decision", "\"decline\"")])) - .unwrap(), - ); - assert_eq!(output(&ws, "B"), json!({"base":10,"decision":"approve"})); - ws.set_policy( - "B", - serde_json::from_value(policy(&[], vec![expression("decision", "\"approve\"")])).unwrap(), - ); - assert_eq!(output(&ws, "B"), json!({"decision":"approve"})); - ws.set_policy( - "B", - serde_json::from_value(policy(&["C"], vec![expression("more", "base + 1")])).unwrap(), - ); - assert_eq!( - output(&ws, "B"), - json!({"base":10,"decision":"decline","more":11}) - ); -} - -#[test] -fn unrelated_sibling_input_and_entity_scope_cannot_poison_evaluation() { - let docs = vec![ - ("shared", policy(&[], vec![])), - ( - "Test", - policy( - &["shared"], - vec![ - json!({"id":"dm", "type":"dataModel", "props":{"data":{"name":"person", "properties":[{"id":"age", "name":"age", "type":"number"}]}}}), - expression("decision", "age > 50"), - ], - ), - ), - ( - "underwriting", - policy( - &["shared"], - vec![ - json!({"id":"rule", "type":"decisionTable", "props":{"data":{ - "hitPolicy":"first", "inputs":[], "outputs":[ - {"id":"x", "name":"", "field":"person.result"}, - {"id":"y", "name":"", "field":"other.result"} - ], "rules":[{"_id":"r1","x":"1","y":"2"}] - }}}), - ], - ), - ), - ]; - let ws = workspace(&docs); - let errors: Vec<_> = ws - .diagnostics("underwriting") - .into_iter() - .filter(|d| d.severity == Severity::Error) - .collect(); - assert!(errors.is_empty(), "{errors:?}"); - assert_eq!( - output(&ws, "underwriting"), - json!({"person":{"result":1},"other":{"result":2}}) - ); - assert!(ws - .enhance_trace(&request("underwriting", json!({}))) - .is_ok()); -} diff --git a/core/engine/tests/policy_slot.rs b/core/engine/tests/policy_slot.rs deleted file mode 100644 index b48e67fa..00000000 --- a/core/engine/tests/policy_slot.rs +++ /dev/null @@ -1,1891 +0,0 @@ -use serde_json::{json, Value}; -use zen_engine::model::DecisionContent; -use zen_engine::policy::{ - 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; - -fn policy_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": "customer", - "properties": [ - { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "tier", "type": "string", "enum": ["gold", "silver"], "array": false, "optional": false }, - { "id": "p3", "name": "name", "type": "string", "array": false, "optional": false }, - { "id": "p4", "name": "status", "type": "relationship", "target": "status", "array": false, "optional": false }, - { "id": "p5", "name": "stage", "type": "relationship", "target": "status", "array": false, "optional": false } - ] - } }, - "children": [] - }, - { - "id": "dt", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ - { "id": "in_declared", "name": "Age", "field": "customer.age" }, - { "id": "in_dict", "name": "Status", "field": "customer.status" }, - { "id": "in_stage", "name": "Stage", "field": "customer.stage" }, - { "id": "in_computed", "name": "Next age", "field": "customer.age + 1" }, - { "id": "in_condition", "name": "Condition" } - ], - "outputs": [ - { "id": "out_dict", "name": "Level", "field": "customer.level", "type": "status" }, - { "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_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", "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": [] - }, - { - "id": "ex_declared", - "type": "expression", - "props": { "data": { "key": "customer.tier", "value": "\"gold\"" } } - }, - { - "id": "ex_computed", - "type": "expression", - "props": { "data": { "key": "customer.total", "value": "customer.age * 2" } } - }, - { - "id": "as1", - "type": "assertion", - "props": { "data": { - "output": "customer.isAdult", - "conditions": [ - { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } - ] - } }, - "children": [] - }, - { - "id": "m_declared", - "type": "match", - "props": { "data": { - "key": "customer.status", - "arms": [ - { "id": "a1", "condition": "customer.age > 50", "value": "\"open\"" }, - { "id": "a2", "condition": "", "value": "\"closed\"" } - ] - } } - }, - { - "id": "m_union", - "type": "match", - "props": { "data": { - "key": "customer.group", - "arms": [ - { "id": "b1", "condition": "customer.age > 50", "value": "\"senior\"" }, - { "id": "b2", "condition": "customer.age > 12", "value": "\"teen\"" }, - { "id": "b3", "condition": "", "value": "\"kid\"" } - ] - } } - }, - { - "id": "m_opaque", - "type": "match", - "props": { "data": { - "key": "customer.alias", - "arms": [ - { "id": "d1", "condition": "customer.age > 50", "value": "customer.name" }, - { "id": "d2", "condition": "", "value": "\"n/a\"" } - ] - } } - } - ] - }); - let mut ws = PolicyWorkspace::new(); - ws.set_policy( - "policy", - serde_json::from_value(doc).expect("valid policy fixture"), - ); - ws -} - -fn cursor(block: &str, target: CursorTarget) -> Cursor { - Cursor { - policy_path: "policy".into(), - block_id: block.into(), - pos: 0, - target, - } -} - -#[test] -fn unicode_completions_and_nested_member_receivers_use_slot_context() { - for source in [ - "\"é\" == customer.", - "\"😀\" == customer.", - " \"😀\" == customer.", - "customer.age in [customer.", - ] { - let mut ws = Workspace::new(); - ws.set_policy("policy", serde_json::from_value(json!({"blocks":[ - {"id":"dm", "type":"dataModel", "props":{"data":{"name":"customer", "properties":[ - {"id":"name", "name":"name", "type":"string", "array":false, "optional":false}, - {"id":"age", "name":"age", "type":"number", "array":false, "optional":false} - ]}}}, - {"id":"ex_computed", "type":"expression", "props":{"data":{"key":"result", "value":source}}} - ]})).unwrap()); - let mut at = cursor("ex_computed", expression("ex_computed")); - at.pos = source.encode_utf16().count() as u32; - let items = ws.completions(&at); - let expected = if source.starts_with("customer.age in") { - "age" - } else { - "name" - }; - assert!( - items.iter().any(|item| item.label == expected), - "{source}: {items:?}" - ); - assert!(!items.iter().any(|item| item.label == "year"), "{source}"); - } -} - -#[test] -fn scope_before_isolates_nested_writes_and_the_declared_schema() { - let mut ws = Workspace::new(); - ws.set_policy("policy", serde_json::from_value(json!({"blocks":[ - {"id":"dm", "type":"dataModel", "props":{"data":{"name":"customer", "properties":[ - {"id":"name", "name":"name", "type":"string", "optional":false, "array":false} - ]}}}, - {"id":"a", "type":"expression", "props":{"data":{"key":"customer.first", "value":"1"}}}, - {"id":"b", "type":"expression", "props":{"data":{"key":"customer.risk", "value":"\"high\""}}}, - {"id":"c", "type":"expression", "props":{"data":{"key":"customer.last", "value":"2"}}} - ]})).unwrap()); - for block in ["a", "b", "c", "a"] { - let response = slot_at(&ws, block, expression(block), "customer.risk == "); - assert_eq!( - response.slot.options.iter().any(|o| o.value == "high"), - block == "c", - "{block}" - ); - } -} - -#[test] -fn optional_outputs_and_collect_cells_keep_their_actual_value_type() { - let mut ws = Workspace::new(); - ws.set_policy("policy", serde_json::from_value(json!({"blocks":[ - {"id":"dm", "type":"dataModel", "props":{"data":{"name":"customer", "properties":[ - {"id":"stage", "name":"stage", "type":"string", "enum":["open","closed"], "optional":true, "array":false}, - {"id":"tags", "name":"tags", "type":"string", "enum":["open","closed"], "optional":false, "array":true} - ]}}}, - {"id":"e", "type":"expression", "props":{"data":{"key":"customer.stage", "value":""}}}, - {"id":"dt", "type":"decisionTable", "props":{"data":{"hitPolicy":"collect", "inputs":[], - "outputs":[{"id":"out", "name":"Tags", "field":"customer.tags"}], "rules":[{"_id":"r", "out":""}]}}} - ]})).unwrap()); - let optional = slot_at(&ws, "e", expression("e"), ""); - assert!(optional - .slot - .options - .iter() - .any(|o| o.source.as_deref() == Some("null"))); - let collect = slot_at(&ws, "dt", cell("r", "out"), ""); - assert!(matches!( - collect.expected_type, - Some(VariableType::Enum(..)) - )); - assert_eq!(collect.slot.options[0].source.as_deref(), Some("\"open\"")); -} - -#[test] -fn graph_output_path_locates_nested_schema_and_bulk_facts_exclude_active_rows() { - let mut ws = Workspace::new(); - let schema = json!({"type":"object", "required":["result"], "properties":{"result":{ - "type":"object", "required":["status"], "properties":{"status":{"type":"string", "enum":["open","closed"]}} - }}}); - ws.set_document("g", document(json!({"nodes":[ - node("in", "inputNode", json!({})), - node("calc", "expressionNode", json!({"outputPath":"result", "expressions":[{"id":"e", "key":"status", "value":""}]})), - node("out", "outputNode", json!({"schema":schema.to_string()})) - ], "edges":[edge("a","in","calc"), edge("b","calc","out")]}))); - let slot = slot_at_graph(&ws, "calc", expression("e"), ""); - assert_eq!(slot.slot.options.len(), 2); - - let ws = graph_workspace(); - for fact in ws.facts("g") { - if fact.block_id.as_ref() != "dt_untyped" { - continue; - } - let at = graph_cursor("dt_untyped", fact.target.clone()); - let live = ws.slot(&at, &fact.source).unwrap(); - assert_eq!(fact.expected_type, live.expected_type); - } -} - -fn cell(row: &str, col: &str) -> CursorTarget { - CursorTarget::DecisionTableCell { - row: row.into(), - col: col.into(), - } -} - -fn head(col: &str) -> CursorTarget { - CursorTarget::DecisionTableHead { col: col.into() } -} - -fn expression(id: &str) -> CursorTarget { - CursorTarget::Expression { id: id.into() } -} - -fn scope_of(ws: &Workspace, cursor: &Cursor) -> CursorScope { - ws.cursor_scope(cursor) - .unwrap_or_else(|| panic!("scope for {:?}", cursor.target)) -} - -fn enum_values(t: &VariableType) -> Vec { - match t { - VariableType::Enum(_, values) => { - let mut out: Vec = values.iter().map(|v| v.to_string()).collect(); - out.sort(); - out - } - other => panic!("expected enum, got {other:?}"), - } -} - -fn assert_path(scope: &CursorScope) { - assert_eq!(scope.kind, ExpressionKind::Standard); - assert_eq!(scope.role, SlotRole::Path); - assert!(scope.expected.is_none()); - assert!(scope.subject_type().is_none()); -} - -fn assert_condition(scope: &CursorScope) { - assert_eq!(scope.kind, ExpressionKind::Standard); - assert_eq!(scope.role, SlotRole::Condition); - assert_eq!(scope.expected, Some(VariableType::Bool)); - assert_eq!(scope.subject_type(), Some(VariableType::Bool)); -} - -#[test] -fn table_input_cell_with_declared_head_is_unary_over_field_type() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "in_declared"))); - assert_eq!(scope.kind, ExpressionKind::Unary); - assert_eq!(scope.role, SlotRole::Unary); - assert_eq!(scope.scope.get("$"), VariableType::Number); - assert_eq!(scope.subject_type(), Some(VariableType::Number)); - assert!(scope.expected.is_none()); - assert_eq!(scope.scope.get("customer").get("age"), VariableType::Number); -} - -#[test] -fn table_input_cell_with_dictionary_head_exposes_enum_subject() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "in_dict"))); - assert_eq!(scope.role, SlotRole::Unary); - assert_eq!(enum_values(&scope.scope.get("$")), vec!["closed", "open"]); -} - -#[test] -fn table_input_cell_with_computed_head_types_subject_by_analysis() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r2", "in_computed"))); - assert_eq!(scope.kind, ExpressionKind::Unary); - assert_eq!(scope.subject_type(), Some(VariableType::Number)); -} - -#[test] -fn table_input_cell_without_head_is_a_condition() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "in_condition"))); - assert_condition(&scope); -} - -#[test] -fn table_output_cell_uses_declared_dictionary_type() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_dict"))); - assert_eq!(scope.kind, ExpressionKind::Standard); - assert_eq!(scope.role, SlotRole::Value); - let expected = scope.expected.as_ref().expect("dictionary type"); - assert_eq!(enum_values(expected), vec!["closed", "open"]); - assert_eq!(scope.subject_type().as_ref(), Some(expected)); -} - -#[test] -fn table_output_cell_uses_declared_primitive_type() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_number"))); - assert_eq!(scope.role, SlotRole::Value); - assert_eq!(scope.expected, Some(VariableType::Number)); -} - -#[test] -fn table_output_cell_falls_back_to_declared_field_type() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_declared"))); - assert_eq!(scope.role, SlotRole::Value); - let expected = scope.expected.expect("declared field type"); - assert_eq!(enum_values(&expected), vec!["gold", "silver"]); -} - -#[test] -fn table_output_cell_falls_back_to_literal_union_of_cells() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_union"))); - let expected = scope.expected.expect("union of cell literals"); - assert_eq!(enum_values(&expected), vec!["a", "b"]); -} - -#[test] -fn table_output_cell_without_literal_type_has_no_expectation() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("dt", cell("r1", "out_untyped"))); - assert_eq!(scope.role, SlotRole::Value); - 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(); - let scope = scope_of(&ws, &cursor("dt", cell("new-row", "in_declared"))); - assert_eq!(scope.kind, ExpressionKind::Unary); - assert!(ws - .cursor_scope(&cursor("dt", cell("r1", "missing-column"))) - .is_none()); -} - -#[test] -fn table_heads_are_paths() { - let ws = policy_workspace(); - assert_path(&scope_of(&ws, &cursor("dt", head("in_computed")))); - assert_path(&scope_of(&ws, &cursor("dt", head("out_dict")))); - assert!(ws.cursor_scope(&cursor("dt", head("missing"))).is_none()); -} - -#[test] -fn expression_value_expects_declared_key_type() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("ex_declared", expression("ex_declared"))); - assert_eq!(scope.kind, ExpressionKind::Standard); - assert_eq!(scope.role, SlotRole::Value); - let expected = scope.expected.expect("declared tier enum"); - assert_eq!(enum_values(&expected), vec!["gold", "silver"]); -} - -#[test] -fn expression_value_with_undeclared_key_has_no_expectation() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("ex_computed", expression("ex_computed"))); - assert_eq!(scope.role, SlotRole::Value); - assert!(scope.expected.is_none()); -} - -#[test] -fn expression_key_is_a_path() { - let ws = policy_workspace(); - assert_path(&scope_of( - &ws, - &cursor("ex_declared", CursorTarget::ExpressionKey), - )); -} - -#[test] -fn assertion_condition_is_standard_bool_condition() { - let ws = policy_workspace(); - let scope = scope_of(&ws, &cursor("as1", expression("c1"))); - assert_condition(&scope); - assert_eq!(scope.scope.get("customer").get("age"), VariableType::Number); -} - -#[test] -fn assertion_output_is_a_path() { - let ws = policy_workspace(); - assert_path(&scope_of( - &ws, - &cursor("as1", CursorTarget::AssertionOutput), - )); -} - -#[test] -fn assertion_condition_completions_are_standard_not_unary() { - let ws = policy_workspace(); - let cursor = Cursor { - policy_path: "policy".into(), - block_id: "as1".into(), - pos: "customer.".len() as u32, - target: expression("c1"), - }; - let labels: Vec = ws - .completions(&cursor) - .into_iter() - .map(|c| c.label.to_string()) - .collect(); - assert!(labels.iter().any(|l| l == "age"), "{labels:?}"); - let inspect = ws - .inspect(&Cursor { pos: 0, ..cursor }) - .expect("inspect customer"); - assert!(!matches!(inspect.kind, VariableType::Bool), "{inspect:?}"); -} - -#[test] -fn match_condition_is_bool_and_target_is_path() { - let ws = policy_workspace(); - assert_condition(&scope_of(&ws, &cursor("m_declared", expression("a1")))); - assert_path(&scope_of( - &ws, - &cursor("m_declared", CursorTarget::MatchTarget), - )); -} - -#[test] -fn match_value_expects_declared_key_type() { - let ws = policy_workspace(); - let scope = scope_of( - &ws, - &cursor("m_declared", CursorTarget::MatchValue { id: "a1".into() }), - ); - assert_eq!(scope.role, SlotRole::Value); - let expected = scope.expected.expect("declared status dictionary"); - assert_eq!(enum_values(&expected), vec!["closed", "open"]); -} - -#[test] -fn match_value_with_undeclared_key_does_not_constrain_outputs_to_sibling_literals() { - let ws = policy_workspace(); - for id in ["b1", "b2", "b3"] { - let target = CursorTarget::MatchValue { id: id.into() }; - let scope = scope_of(&ws, &cursor("m_union", target.clone())); - assert!(scope.expected.is_none()); - let response = slot_at(&ws, "m_union", target, "\"teen\""); - assert!(response.literals.is_empty()); - assert!(response.slot.options.is_empty()); - } - for entry in ws.facts("policy").iter().filter(|entry| { - entry.block_id.as_ref() == "m_union" - && matches!(entry.target, CursorTarget::MatchValue { .. }) - }) { - assert!(entry.expected_type.is_none()); - assert!(entry.literals.is_empty()); - } -} - -#[test] -fn match_outputs_still_infer_enum_values_for_downstream_conditions() { - let mut ws = PolicyWorkspace::new(); - ws.set_policy( - "policy", - serde_json::from_value(json!({"blocks": [ - {"id": "decision", "type": "match", "props": {"data": { - "key": "decision", "arms": [ - {"id": "a", "condition": "true", "value": "\"DECLINE\""}, - {"id": "b", "condition": "false", "value": "\"DECLINE\""}, - {"id": "c", "condition": "", "value": "\"APPROVE\""} - ] - }}}, - {"id": "amount", "type": "match", "props": {"data": { - "key": "amount", "arms": [ - {"id": "d", "condition": "decision == \"APPROVE\"", "value": "100"}, - {"id": "e", "condition": "", "value": "0"} - ] - }}} - ]})) - .unwrap(), - ); - let output = slot_at( - &ws, - "decision", - CursorTarget::MatchValue { id: "a".into() }, - "\"DECLINE\"", - ); - assert!(output.literals.is_empty()); - let condition = slot_at(&ws, "amount", expression("d"), "decision == \"APPROVE\""); - assert!(matches!( - &condition.literals[..], - [LiteralFact::Enum { valid: true, .. }] - )); -} - -#[test] -fn match_value_with_non_literal_siblings_has_no_expectation() { - let ws = policy_workspace(); - let scope = scope_of( - &ws, - &cursor("m_opaque", CursorTarget::MatchValue { id: "d2".into() }), - ); - assert_eq!(scope.role, SlotRole::Value); - assert!(scope.expected.is_none()); -} - -#[test] -fn data_model_targets_and_unknown_blocks_have_no_scope() { - let ws = policy_workspace(); - assert!(ws - .cursor_scope(&cursor("dm", CursorTarget::DataModelName)) - .is_none()); - assert!(ws - .cursor_scope(&cursor( - "dm", - CursorTarget::DataModelProperty { id: "p1".into() } - )) - .is_none()); - assert!(ws - .cursor_scope(&cursor("missing", expression("x"))) - .is_none()); - assert!(ws - .cursor_scope(&cursor("as1", CursorTarget::MatchTarget)) - .is_none()); -} - -fn document(value: Value) -> DecisionContent { - serde_json::from_value(value).expect("valid decision content") -} - -fn node(id: &str, kind: &str, content: Value) -> Value { - json!({ "id": id, "name": id, "type": kind, "content": content }) -} - -fn edge(id: &str, source: &str, target: &str) -> Value { - json!({ "id": id, "sourceId": source, "targetId": target, "sourceHandle": null }) -} - -fn graph_workspace() -> Workspace { - let mut ws = Workspace::new(); - ws.set_document( - "dicts", - document(json!({ - "imports": [], - "blocks": [{ - "id": "dict1", - "type": "dictionary", - "props": { "data": { - "name": "customerTier", - "entries": [ - { "id": "e0", "value": "VIP", "label": "Very important" }, - { "id": "e1", "value": "STD", "label": "Standard" } - ] - } } - }] - })), - ); - let input_schema = json!({ - "type": "object", - "properties": { "age": { "type": "number" }, "name": { "type": "string" } }, - "required": ["age", "name"] - }); - let output_schema = json!({ - "type": "object", - "properties": { - "total": { "type": "number" }, - "vip": { "type": "boolean" }, - "level": { "$dictionary": "customerTier" } - }, - "required": ["total", "vip", "level"] - }); - ws.set_document( - "g", - document(json!({ - "imports": ["dicts"], - "nodes": [ - node("in", "inputNode", json!({ "schema": input_schema.to_string() })), - node("calc", "expressionNode", json!({ "passThrough": true, "expressions": [ - { "id": "row_total", "key": "total", "value": "age * 2" }, - { "id": "row_vip", "key": "vip", "value": "age > 60" }, - { "id": "row_extra", "key": "extra", "value": "name" } - ] })), - node("sw", "switchNode", json!({ "statements": [ - { "id": "s1", "condition": "age > 18" } - ] })), - node("dt", "decisionTableNode", json!({ - "hitPolicy": "first", - "inputs": [ - { "id": "c_field", "name": "Age", "field": "age" }, - { "id": "c_computed", "name": "Next", "field": "age + 1" }, - { "id": "c_condition", "name": "Condition" } - ], - "outputs": [ - { "id": "o_dict", "name": "Tier", "field": "tier", "type": "customerTier" }, - { "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", "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() })) - ], - "edges": [ - edge("e1", "in", "calc"), - edge("e2", "calc", "sw"), - edge("e3", "sw", "dt"), - edge("e4", "dt", "out"), - edge("e5", "sw", "dt_untyped"), - edge("e6", "dt_untyped", "out") - ] - })), - ); - ws -} - -fn graph_cursor(node: &str, target: CursorTarget) -> Cursor { - Cursor { - policy_path: "g".into(), - block_id: node.into(), - pos: 0, - target, - } -} - -#[test] -fn graph_expression_row_is_value_with_output_schema_type() { - let ws = graph_workspace(); - let scope = scope_of(&ws, &graph_cursor("calc", expression("row_total"))); - assert_eq!(scope.kind, ExpressionKind::Standard); - assert_eq!(scope.role, SlotRole::Value); - assert_eq!(scope.expected, Some(VariableType::Number)); - assert_eq!(scope.scope.get("age"), VariableType::Number); - assert!(!matches!(scope.scope.get("$"), VariableType::Null)); - assert!(!matches!(scope.scope.get("$nodes"), VariableType::Null)); - - let scope = scope_of(&ws, &graph_cursor("calc", expression("row_vip"))); - assert_eq!(scope.expected, Some(VariableType::Bool)); - - let scope = scope_of(&ws, &graph_cursor("calc", expression("row_extra"))); - assert!(scope.expected.is_none()); -} - -#[test] -fn graph_switch_statement_is_a_condition() { - let ws = graph_workspace(); - let scope = scope_of(&ws, &graph_cursor("sw", expression("s1"))); - assert_condition(&scope); - assert_eq!(scope.scope.get("age"), VariableType::Number); -} - -#[test] -fn graph_table_cells_resolve_like_policy_tables() { - let ws = graph_workspace(); - let scope = scope_of(&ws, &graph_cursor("dt", cell("r1", "c_field"))); - assert_eq!(scope.kind, ExpressionKind::Unary); - assert_eq!(scope.role, SlotRole::Unary); - assert_eq!(scope.subject_type(), Some(VariableType::Number)); - - let scope = scope_of(&ws, &graph_cursor("dt", cell("r1", "c_computed"))); - assert_eq!(scope.subject_type(), Some(VariableType::Number)); - - assert_condition(&scope_of( - &ws, - &graph_cursor("dt", cell("r1", "c_condition")), - )); - - let scope = scope_of(&ws, &graph_cursor("dt", cell("r1", "o_dict"))); - assert_eq!(scope.role, SlotRole::Value); - assert_eq!( - enum_values(&scope.expected.expect("dictionary column")), - vec!["STD", "VIP"] - ); - - 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(); - assert_path(&scope_of(&ws, &graph_cursor("dt", head("c_computed")))); - assert_path(&scope_of(&ws, &graph_cursor("dt", head("o_dict")))); - let scope = scope_of(&ws, &graph_cursor("calc", CursorTarget::TransformInput)); - assert_path(&scope); - assert_eq!(scope.scope.get("age"), VariableType::Number); - assert!(ws - .cursor_scope(&graph_cursor("sw", CursorTarget::TransformInput)) - .is_none()); - assert!(ws - .cursor_scope(&graph_cursor("in", expression("x"))) - .is_none()); -} - -fn slot_at(ws: &Workspace, block: &str, target: CursorTarget, text: &str) -> SlotResponse { - let mut cursor = cursor(block, target); - cursor.pos = text.encode_utf16().count() as u32; - 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() -} - -#[test] -fn slot_table_unary_cell_uses_dictionary_labels() { - let ws = policy_workspace(); - let response = slot_at(&ws, "dt", cell("r1", "in_stage"), "== \""); - assert_eq!(response.kind, ExpressionKind::Unary); - assert_eq!(response.role, SlotRole::Unary); - assert_eq!(response.slot.state, SlotState::InString); - assert_eq!(response.slot.replace_span, (3, 4)); - assert_eq!(labels(&response.slot.options), vec!["Open case", "Closed"]); - assert!(response.slot.auto_open); - assert_eq!( - enum_values(&response.subject_type.expect("unary subject")), - vec!["closed", "open"] - ); - assert!(response.expected_type.is_none()); -} - -#[test] -fn slot_match_value_offers_declared_enum_options() { - let ws = policy_workspace(); - let response = slot_at( - &ws, - "m_declared", - CursorTarget::MatchValue { id: "a1".into() }, - "", - ); - 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!["Open case", "Closed"]); - assert_eq!( - enum_values(&response.expected_type.expect("declared key type")), - vec!["closed", "open"] - ); -} - -#[test] -fn slot_assertion_condition_expects_a_number_after_comparison() { - let ws = policy_workspace(); - let response = slot_at(&ws, "as1", expression("c1"), "customer.age >= "); - assert_eq!(response.kind, ExpressionKind::Standard); - assert_eq!(response.role, SlotRole::Condition); - assert_eq!(response.expected_type, Some(VariableType::Bool)); - assert_eq!(response.slot.state, SlotState::Value); - assert_eq!(response.slot.expected, Some(VariableType::Number)); - assert_eq!(response.slot.replace_span, (16, 16)); -} - -#[test] -fn slot_graph_switch_statement_is_a_condition() { - let ws = graph_workspace(); - let mut cursor = graph_cursor("sw", expression("s1")); - let text = "age > "; - cursor.pos = text.len() as u32; - let response = ws.slot(&cursor, text).expect("graph slot"); - assert_eq!(response.role, SlotRole::Condition); - assert_eq!(response.slot.state, SlotState::Value); - assert_eq!(response.slot.expected, Some(VariableType::Number)); - 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(); - let text = "customer.name == \"😀東京\" and customer.stage == \""; - let units = text.encode_utf16().count() as u32; - assert_eq!(units, 47); - assert_eq!(text.len(), 53); - let response = slot_at(&ws, "dt", cell("r1", "in_condition"), text); - assert_eq!(response.slot.state, SlotState::InString); - assert_eq!(response.slot.replace_span, (units - 1, units)); - assert_eq!(labels(&response.slot.options), vec!["Open case", "Closed"]); - - let text = "customer.name == \"😀\" and customer.stage == \"open\""; - let response = slot_at(&ws, "dt", cell("r1", "in_condition"), text); - let fact = response - .literals - .iter() - .find_map(|f| match f { - LiteralFact::Enum { span, label, .. } => Some((*span, label.clone())), - _ => None, - }) - .expect("enum literal"); - assert_eq!(fact, ((44, 50), "Open case".to_string())); -} - -#[test] -fn slot_is_none_outside_expression_targets() { - let ws = policy_workspace(); - assert!(ws - .slot(&cursor("dm", CursorTarget::DataModelName), "x") - .is_none()); - assert!(ws.slot(&cursor("missing", expression("x")), "x").is_none()); -} - -#[test] -fn facts_match_per_target_slot_literals_and_carry_labels() { - let ws = policy_workspace(); - let facts = ws.facts("policy"); - assert_eq!(facts.len(), 57); - for entry in &facts { - let response = ws - .slot( - &cursor(&entry.block_id, entry.target.clone()), - &entry.source, - ) - .expect("every fact target resolves"); - assert_eq!(response.kind, entry.kind); - assert_eq!(response.role, entry.role); - assert_eq!(response.literals, entry.literals, "{:?}", entry.target); - assert_eq!(response.enums, entry.enums, "{:?}", entry.target); - } - - let find = |block: &str, target: CursorTarget| { - let wanted = serde_json::to_value(&target).unwrap(); - facts - .iter() - .find(|f| { - f.block_id.as_ref() == block && serde_json::to_value(&f.target).unwrap() == wanted - }) - .expect("fact present") - }; - let open = find("dt", cell("r1", "in_stage")); - assert_eq!(open.kind, ExpressionKind::Unary); - assert!(matches!( - &open.literals[..], - [LiteralFact::Enum { label, valid: true, span: (0, 6), .. }] if label == "Open case" - )); - assert_eq!(labels(&open.subject_options), vec!["Open case", "Closed"]); - - let empty = find("dt", cell("r2", "in_stage")); - assert!(empty.source.is_empty()); - assert!(empty.literals.is_empty()); - assert_eq!(labels(&empty.subject_options), vec!["Open case", "Closed"]); - - let closed = find("dt", cell("r2", "out_dict")); - assert_eq!(closed.role, SlotRole::Value); - assert!(matches!( - &closed.literals[..], - [LiteralFact::Enum { label, .. }] if label == "Closed" - )); - - let head = find("dt", head("in_stage")); - assert_eq!(head.role, SlotRole::Path); - assert!(head.literals.is_empty()); - - let arm = find("m_declared", CursorTarget::MatchValue { id: "a2".into() }); - assert!(matches!(&arm.literals[..], [LiteralFact::Enum { label, .. }] if label == "Closed")); - assert_eq!(labels(&arm.subject_options), vec!["Open case", "Closed"]); -} - -#[test] -fn graph_facts_carry_imported_dictionary_labels() { - let ws = graph_workspace(); - let facts = ws.facts("g"); - assert!(!facts.is_empty()); - let vip = 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() == "o_dict") - }) - .expect("o_dict cell"); - assert_eq!(vip.role, SlotRole::Value); - assert!(matches!( - &vip.literals[..], - [LiteralFact::Enum { label, valid: true, .. }] if label == "Very important" - )); - assert_eq!( - labels(&vip.subject_options), - vec!["Very important", "Standard"] - ); - - let total = facts - .iter() - .find(|f| f.block_id.as_ref() == "calc" && matches!(&f.target, CursorTarget::Expression { id } if id.as_ref() == "row_total")) - .expect("expression row"); - assert_eq!(total.expected_type, Some(VariableType::Number)); - assert!(ws.facts("nope").is_empty()); -} - -#[test] -fn facts_cover_every_assertion_condition_with_exact_source() { - let doc = json!({ - "blocks": [ - { - "id": "dict1", - "type": "dictionary", - "props": { "data": { - "name": "status", - "entries": [ - { "id": "e0", "value": "aog", "label": "Aircraft on ground" }, - { "id": "e1", "value": "ok", "label": "Serviceable" } - ] - } } - }, - { - "id": "dm", - "type": "dataModel", - "props": { "data": { - "name": "grounding", - "properties": [ - { "id": "p1", "name": "status", "type": "relationship", "target": "status", "array": false, "optional": false }, - { "id": "p2", "name": "hours", "type": "number", "array": false, "optional": false } - ] - } }, - "children": [] - }, - { - "id": "as_two", - "type": "assertion", - "props": { "data": { - "output": "grounding.blocked", - "conditions": [ - { "id": "cond_a", "expression": " grounding.status == \"aog\" ", "operator": "and", "depth": 0 }, - { "id": "cond_b", "expression": "grounding.hours > 4 ", "operator": "and", "depth": 0 } - ] - } }, - "children": [] - } - ] - }); - let mut ws = PolicyWorkspace::new(); - ws.set_policy( - "policy", - serde_json::from_value(doc).expect("valid policy fixture"), - ); - let facts = ws.facts("policy"); - let find = |id: &str| { - facts - .iter() - .find(|f| { - f.block_id.as_ref() == "as_two" - && matches!(&f.target, CursorTarget::Expression { id: got } if got.as_ref() == id) - }) - .unwrap_or_else(|| panic!("facts entry for condition {id}")) - }; - let first = find("cond_a"); - assert_eq!(first.source.as_ref(), " grounding.status == \"aog\" "); - assert_eq!(first.role, SlotRole::Condition); - assert!(matches!( - &first.literals[..], - [LiteralFact::Enum { label, valid: true, span: (21, 26), .. }] if label == "Aircraft on ground" - )); - let second = find("cond_b"); - 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)); - assert_eq!(scope.scope.get("$"), VariableType::String); - let response = slot_at(&ws, "dt", cell("r2", "in_date"), "> "); - assert_eq!(response.slot.expected, Some(VariableType::Date)); - let response = slot_at(&ws, "dt", cell("r2", "in_date"), "$."); - assert_eq!(response.slot.operand, Some(VariableType::String)); - 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 { - import_scope_workspace(false) -} - -fn import_scope_workspace(import_rule: bool) -> 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": if import_rule { vec!["shared", "rule"] } else { vec!["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 explicitly_imported_policy_writes_stay_visible_downstream() { - let ws = import_scope_workspace(true); - - 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(".")); -} - -#[test] -fn inspect_and_rename_use_utf16_after_non_ascii_literals() { - let mut ws = PolicyWorkspace::new(); - let source = "\"😀é\" + customer.name"; - ws.set_policy("p", serde_json::from_value(json!({"blocks":[ - {"id":"dm", "type":"dataModel", "props":{"data":{"name":"customer", "properties":[ - {"id":"name", "name":"name", "type":"string", "optional":false, "array":false} - ]}}}, - {"id":"ex_computed", "type":"expression", "props":{"data":{"key":"result", "value":source}}} - ]})).unwrap()); - let mut at = cursor_in("p", "ex_computed", expression("ex_computed")); - let start = source[..source.find("name").unwrap()] - .encode_utf16() - .count() as u32; - at.pos = start + 2; - let info = ws.inspect(&at).expect("member inspection"); - assert!(info.span.0 <= start && info.span.1 >= start + 4, "{info:?}"); - let rename = ws.prepare_rename(&at).expect("field rename"); - assert_eq!(rename.span, (start, start + 4)); - let edits = ws.rename(&rename.target, "fullName"); - assert!( - edits.iter().any(|edit| match edit { - zen_engine::policy::EngineEdit::ReplaceBlock { - block_id, - new_block, - .. - } if block_id.as_ref() == "ex_computed" => { - new_block["props"]["data"]["value"] == "\"😀é\" + customer.fullName" - } - _ => false, - }), - "{edits:?}" - ); -} - -#[test] -fn graph_collect_and_loop_expectations_preserve_array_valued_cells() { - for hit_policy in ["first", "collect"] { - for looped in [false, true] { - for collected_column in [false, true] { - let item = json!({"type":"string", "enum":["open","closed"]}); - let field = if collected_column { - json!({"type":"array", "items":item}) - } else { - item - }; - let row = json!({"type":"object", "required":["tags"], "properties":{"tags":{ - "type":"array", "items":field - }}}); - let output = if hit_policy == "collect" { - json!({"type":"array", "items":row}) - } else { - row - }; - let output = if looped { - json!({"type":"array", "items":output}) - } else { - output - }; - let schema = - json!({"type":"object", "required":["result"], "properties":{"result":output}}); - let mut ws = Workspace::new(); - ws.set_document("g", document(json!({"nodes":[ - node("in", "inputNode", json!({})), - node("calc", "decisionTableNode", json!({ - "hitPolicy":hit_policy, "executionMode":if looped { "loop" } else { "single" }, - "outputPath":"result", "passThrough":true, "inputs":[], - "outputs":[{"id":"o", "name":"Tags", "field":if collected_column { "tags[]" } else { "tags" }}], - "rules":[{"_id":"r", "o":""}] - })), - node("out", "outputNode", json!({"schema":schema.to_string()})) - ], "edges":[edge("a","in","calc"), edge("b","calc","out")]}))); - let result = slot_at_graph(&ws, "calc", cell("r", "o"), ""); - assert!( - matches!(result.expected_type, Some(VariableType::Array(_))), - "{hit_policy}/{looped}/{collected_column}: {result:?}" - ); - assert_eq!(result.slot.options[0].source.as_deref(), Some("[\"open\"]")); - } - } - } -} diff --git a/core/engine/tests/policy_table.rs b/core/engine/tests/policy_table.rs index fa135956..93d8d940 100644 --- a/core/engine/tests/policy_table.rs +++ b/core/engine/tests/policy_table.rs @@ -1,8 +1,7 @@ use serde_json::json; use std::sync::Arc; use zen_engine::policy::{ - CursorTarget, EngineEdit, EvaluateRequest, EvaluationError, PolicyWorkspace, RenameTarget, - ScopeRequest, Severity, + EngineEdit, EvaluateRequest, PolicyWorkspace, RenameTarget, ScopeRequest, Severity, }; use zen_expression::variable::{Variable, VariableType}; @@ -72,53 +71,6 @@ fn strict_table_doc() -> serde_json::Value { }) } -#[test] -fn failed_input_comparisons_report_the_field_and_missing_value() { - let ws = workspace_with(strict_table_doc()); - for (input, value_description) in [ - (json!({}), "missing or null"), - (json!({ "order": { "amount": null } }), "missing or null"), - ] { - let error = ws.evaluate(&request(input, true)).unwrap_err(); - let EvaluationError::ExpressionFailed { - block_id, - expression, - source, - .. - } = error - else { - panic!("expected a contextual expression error, got {error:?}"); - }; - assert_eq!(block_id.as_ref(), "dt"); - assert_eq!(expression.as_ref(), ">= 100"); - let serialized = serde_json::to_value(&source).unwrap(); - assert_eq!(serialized["type"], "contextError"); - assert_eq!(serialized["source"]["type"], "vmError"); - let source = source.to_string(); - assert!(source.contains("order.amount"), "{source}"); - assert!(source.contains(">= 100"), "{source}"); - assert!(source.contains(value_description), "{source}"); - assert!(source.contains("Opcode Compare"), "{source}"); - } - // A failed run must not poison the workspace or silently select a fallback rule. - assert_eq!( - evaluate_output(&ws, json!({ "order": { "amount": 100, "region": "US" } })) - .pointer("/order/shippingCost"), - Some(&json!(0)) - ); -} - -#[test] -fn a_condition_that_handles_missing_input_still_evaluates_normally() { - let mut doc = strict_table_doc(); - doc["blocks"][1]["props"]["data"]["rules"][0]["i1"] = json!("null"); - let ws = workspace_with(doc); - assert_eq!( - evaluate_output(&ws, json!({ "order": { "region": "US" } })).pointer("/order/shippingCost"), - Some(&json!(0)) - ); -} - #[test] fn no_match_emits_null_for_scalar_output() { let ws = workspace_with(strict_table_doc()); @@ -638,82 +590,3 @@ fn equality_index_matches_linear_semantics() { assert_eq!(bucket_for("EU", json!(2.5), false), json!("r8")); assert_eq!(bucket_for("US", json!(999), false), json!("r9")); } - -#[test] -fn table_diagnostics_carry_cell_and_head_targets() { - let ws = workspace_with(json!({ - "blocks": [ - order_dm(), - { "id": "dt", "type": "decisionTable", "props": { "data": { - "inputs": [ - { "id": "i1", "name": "", "field": "order.amount" }, - { "id": "i2", "name": "", "field": "order.amount +" } - ], - "outputs": [ { "id": "o1", "name": "", "field": "order.shippingCost", "type": "number" } ], - "rules": [ - { "_id": "r1", "i1": ">= 100", "i2": "", "o1": "\"free\"" }, - { "_id": "r2", "i1": "> ", "i2": "", "o1": "5" } - ] - } } } - ] - })); - let diagnostics = ws.diagnostics("p"); - let errors: Vec<_> = diagnostics - .iter() - .filter(|d| d.severity == Severity::Error) - .collect(); - assert!( - errors.iter().any(|d| { - d.message.contains("output cell must be `number`") - && matches!( - &d.location.target, - Some(CursorTarget::DecisionTableCell { row, col }) - if row.as_ref() == "r1" && col.as_ref() == "o1" - ) - }), - "{errors:?}" - ); - assert!( - errors.iter().any(|d| matches!( - &d.location.target, - Some(CursorTarget::DecisionTableCell { row, col }) - if row.as_ref() == "r2" && col.as_ref() == "i1" - )), - "{errors:?}" - ); - assert!( - errors.iter().any(|d| matches!( - &d.location.target, - Some(CursorTarget::DecisionTableHead { col }) if col.as_ref() == "i2" - )), - "{errors:?}" - ); -} - -#[test] -fn diagnostics_carry_expression_codes() { - let mut doc = strict_table_doc(); - doc["blocks"][1]["props"]["data"]["inputs"] - .as_array_mut() - .unwrap() - .push(json!({ "id": "i3", "name": "", "field": "" })); - doc["blocks"][1]["props"]["data"]["rules"] = json!([ - { "i1": "amount ==", "i2": "\"XX\"", "i3": "1 + 1", "o1": "0" } - ]); - let ws = workspace_with(doc); - let diagnostics = ws.diagnostics("p"); - let by_code = |code: &str| { - diagnostics - .iter() - .find(|d| d.expr_code == Some(code)) - .unwrap_or_else(|| panic!("{code} missing in {diagnostics:?}")) - }; - assert_eq!(by_code("expr.missing-value").args["operator"], "=="); - let member = by_code("type.invalid-enum-member"); - assert_eq!(member.args["value"], "XX"); - assert_eq!(member.args["enum"], "\"US\" | \"EU\""); - assert_eq!(by_code("type.condition-not-bool").args["got"], "number"); - let json = serde_json::to_value(by_code("expr.missing-value")).unwrap(); - assert_eq!(json["exprCode"], "expr.missing-value"); - assert_eq!(json["args"]["operator"], "=="); -} diff --git a/core/engine/tests/policy_toml.rs b/core/engine/tests/policy_toml.rs index c91d194d..654212c5 100644 --- a/core/engine/tests/policy_toml.rs +++ b/core/engine/tests/policy_toml.rs @@ -1,11 +1,12 @@ use serde::Deserialize; use std::collections::BTreeMap; use std::sync::Arc; +use zen_engine::model::DecisionContent; use zen_engine::policy::{ Cursor, CursorTarget, EvaluateRequest, PolicyWorkspace, ReferenceKind, RenameTarget, ScopeRequest, }; -use zen_expression::variable::Variable; +use zen_expression::variable::{Variable, VariableType}; const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/policy/fixtures/"); @@ -380,6 +381,128 @@ fn run_completions(file_name: &str, toml_data: &str) { } } +#[derive(Debug, Deserialize)] +struct SlotsFile { + policies: Vec, + #[serde(default)] + graphs: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct SlotsCase { + name: String, + policy: String, + block_id: String, + target: String, + id: Option, + row: Option, + kind: Option, + role: Option, + expected: Option, + subject: Option, + text: Option, + state: Option, + options: Option>, +} + +impl SlotsCase { + fn cursor(&self) -> Cursor { + let id = || Arc::from(self.id.as_deref().expect("target id")); + let target = match self.target.as_str() { + "expression" => CursorTarget::Expression { id: id() }, + "cell" => CursorTarget::DecisionTableCell { + row: Arc::from(self.row.as_deref().expect("row")), + col: id(), + }, + "head" => CursorTarget::DecisionTableHead { col: id() }, + "expression_key" => CursorTarget::ExpressionKey, + "assertion_output" => CursorTarget::AssertionOutput, + "match_target" => CursorTarget::MatchTarget, + "match_value" => CursorTarget::MatchValue { id: id() }, + "transform_input" => CursorTarget::TransformInput, + "data_model_name" => CursorTarget::DataModelName, + other => panic!("unknown target {other}"), + }; + Cursor { + policy_path: Arc::from(self.policy.as_str()), + block_id: Arc::from(self.block_id.as_str()), + pos: 0, + target, + } + } + + fn check(&self, ws: &PolicyWorkspace) { + let ctx = format!("[slots.toml:{}]", self.name); + let mut cursor = self.cursor(); + let Some(scope) = ws.cursor_scope(&cursor) else { + assert!(self.kind.is_none(), "{ctx} expected a scope, got none"); + return; + }; + let render = |t: Option| t.map(|t| t.to_string()).unwrap_or_default(); + let actual = [ + ("kind", format!("{:?}", scope.kind).to_lowercase()), + ( + "role", + serde_json::to_value(scope.role) + .unwrap() + .as_str() + .unwrap() + .to_string(), + ), + ("expected", render(scope.expected.clone())), + ("subject", render(scope.subject_type())), + ]; + let wanted = [&self.kind, &self.role, &self.expected, &self.subject]; + for ((field, actual), wanted) in actual.iter().zip(wanted) { + if let Some(wanted) = wanted { + assert_eq!(actual, wanted, "{ctx} {field}"); + } + } + let Some(text) = &self.text else { + return; + }; + let caret = text.find('|').expect("caret"); + let source = text.replacen('|', "", 1); + cursor.pos = source[..caret].encode_utf16().count() as u32; + let slot = ws.slot(&cursor, &source).expect("slot").slot; + if let Some(state) = &self.state { + assert_eq!( + &serde_json::to_value(slot.state).unwrap(), + state, + "{ctx} state" + ); + } + if let Some(options) = &self.options { + let actual: Vec = slot + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + assert_eq!(&actual, options, "{ctx} options"); + } + } +} + +fn run_slots(file_name: &str, toml_data: &str) { + let file: SlotsFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let mut ws = build_workspace(&file.policies); + for path in &file.graphs { + let raw = std::fs::read_to_string(format!("{FIXTURES_DIR}{path}")) + .unwrap_or_else(|e| panic!("cannot read fixture {path}: {e}")); + let doc: DecisionContent = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("cannot deserialize fixture {path}: {e}")); + ws.set_document(path.as_str(), doc); + } + for test in &file.test { + test.check(&ws); + } +} + #[derive(Debug, Deserialize)] struct EntitiesFile { policies: Vec, @@ -608,3 +731,8 @@ fn entities_merge_multi_policy_toml_cases() { include_str!("data/policy/entities_merge_multi_policy.toml"), ); } + +#[test] +fn slots_toml_cases() { + run_slots("slots.toml", include_str!("data/policy/slots.toml")); +} diff --git a/core/engine/tests/workspace_graph.rs b/core/engine/tests/workspace_graph.rs index a167017d..193f7cc2 100644 --- a/core/engine/tests/workspace_graph.rs +++ b/core/engine/tests/workspace_graph.rs @@ -2909,37 +2909,6 @@ fn decision_boundary_allows_missing_optional_item_field() { ); } -#[test] -fn optional_properties_do_not_warn_of_nullability_divergence() { - let mut ws = Workspace::new(); - let schema = json!({ - "type": "object", - "properties": { - "age": { "type": "number" }, - "name": { "type": "string" }, - "alias": { "type": ["string", "null"] }, - "tags": { - "type": "array", - "items": { - "type": "object", - "properties": { "label": { "type": "string" }, "weight": { "type": "number" } }, - "required": ["label"] - } - } - }, - "required": ["age", "tags"] - }); - ws.set_document( - "g", - document(linear_graph( - Some(schema), - vec![expression_node("calc", &[("x", "age * 2")])], - )), - ); - let diagnostics = ws.diagnostics("g"); - assert!(diagnostics.is_empty(), "{diagnostics:?}"); -} - #[test] fn decision_boundary_names_nullability_delta() { let mut ws = Workspace::new(); @@ -3263,121 +3232,3 @@ fn missing_member_through_local_names_the_member_not_the_alias() { "the specific member must be blamed, not the alias: {errors:?}" ); } - -#[test] -fn formatted_date_inputs_keep_string_runtime_types_and_require_conversion() { - let schema = json!({"type":"object", "required":["since"], "properties":{ - "since":{"type":"string", "format":"date-time"} - }}); - for (source, error, year) in [ - ("since.year()", true, false), - ("d(since).year()", false, false), - ("since < since", true, false), - ("since < d(since)", false, false), - ("since.", true, false), - ("d(since).", true, true), - ] { - let mut ws = Workspace::new(); - ws.set_document( - "g", - document(linear_graph( - Some(schema.clone()), - vec![expression_node("calc", &[("result", source)])], - )), - ); - let diagnostics = ws.diagnostics("g"); - assert_eq!( - diagnostics.iter().any(|d| d.severity == Severity::Error), - error, - "{source}: {diagnostics:?}" - ); - if source.ends_with('.') { - let items = ws.completions(&Cursor { - policy_path: "g".into(), - block_id: "calc".into(), - target: CursorTarget::Expression { - id: "calc-e0".into(), - }, - pos: source.len() as u32, - }); - assert_eq!( - items.iter().any(|item| item.label == "year"), - year, - "{source}" - ); - } - } -} - -#[test] -fn date_calendar_hints_keep_runtime_types_in_transformed_graph_tables() { - for nested in [false, true] { - let field = json!({"anyOf":[{"type":"string","format":"date"},{"type":"null"}]}); - let item = json!({"type":"object","required":["since"],"properties":{"since":field}}); - let schema = if nested { - json!({"type":"object","required":["items"],"properties":{ - "items":{"type":"array","items":item} - }}) - } else { - item - }; - let table = node( - "dt", - "decisionTableNode", - json!({ - "inputField":if nested {"items"} else {""}, - "executionMode":if nested {"loop"} else {"single"}, - "hitPolicy":"first", "inputs":[{"id":"i", "name":"Since", "field":"since"}], - "outputs":[{"id":"o","name":"Result","field":"result"}], - "rules":[{"_id":"r1","i":"> d(\"2024-01-01\")", "o":"true"}] - }), - ); - let mut ws = Workspace::new(); - ws.set_document("g", document(linear_graph(Some(schema), vec![table]))); - let mut cursor = Cursor { - policy_path: "g".into(), - block_id: "dt".into(), - pos: 2, - target: CursorTarget::DecisionTableCell { - row: "r1".into(), - col: "i".into(), - }, - }; - let slot = ws.slot(&cursor, "> ").unwrap(); - assert_eq!( - slot.slot.expected.as_ref().unwrap().unwrap_nullable().0, - &VariableType::Date - ); - cursor.pos = 2; - let slot = ws.slot(&cursor, "$.").unwrap(); - assert_eq!( - slot.slot.operand.as_ref().unwrap().unwrap_nullable().0, - &VariableType::String - ); - let facts = ws.facts("g"); - assert!(facts.iter().any(|f| f - .subject_type - .as_ref() - .is_some_and(|t| t.unwrap_nullable().0 == &VariableType::Date))); - } -} - -#[test] -fn formatted_output_schema_accepts_existing_date_and_string_expressions() { - for source in ["d(\"2024-01-01\")", "\"2024-01-01\""] { - let mut graph = linear_graph( - Some(person_schema()), - vec![expression_node("calc", &[("since", source)])], - ); - graph["nodes"].as_array_mut().unwrap().last_mut().unwrap()["content"] = json!({"schema":json!({ - "type":"object","required":["since"],"properties":{"since":{"type":"string","format":"date"}} - }).to_string()}); - let mut ws = Workspace::new(); - ws.set_document("g", document(graph)); - let diagnostics = ws.diagnostics("g"); - assert!( - diagnostics.iter().all(|d| d.severity != Severity::Error), - "{source}: {diagnostics:?}" - ); - } -} diff --git a/core/expression/src/intellisense/completion.rs b/core/expression/src/intellisense/completion.rs index dea5e0ff..4c796b89 100644 --- a/core/expression/src/intellisense/completion.rs +++ b/core/expression/src/intellisense/completion.rs @@ -30,10 +30,8 @@ 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>, } @@ -47,7 +45,7 @@ impl Completions { data: &VariableType, slot: &crate::slot::Slot, ) -> Vec { - use crate::slot::{field_fits, SlotState}; + use crate::slot::{ScalarClass, SlotState}; if slot.suppress_completions || matches!( slot.state, @@ -71,7 +69,11 @@ impl Completions { _ => Self::build_scope(data, &locals), }; if let Some(wanted) = slot.wanted_scalar() { - items.retain(|item| item.var_type.as_ref().is_none_or(|t| field_fits(t, wanted))); + items.retain(|item| { + item.var_type + .as_ref() + .is_none_or(|t| ScalarClass::fits(t, wanted)) + }); } for item in &mut items { if let Some(t) = &item.var_type { @@ -146,7 +148,6 @@ impl Completions { .as_ref() .and_then(|d| d.param_type(0)) .map(|pt| match pt { - // Constructor arguments accept strings/numbers; method receivers need d(...). VariableType::Date => { matches!(resolved, VariableType::Date | VariableType::Any) } @@ -162,7 +163,6 @@ impl Completions { completions } - /// `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(); diff --git a/core/expression/src/intellisense/type_provider.rs b/core/expression/src/intellisense/type_provider.rs index f20486ec..96e28a6c 100644 --- a/core/expression/src/intellisense/type_provider.rs +++ b/core/expression/src/intellisense/type_provider.rs @@ -399,7 +399,6 @@ impl TypesProvider { | ComparisonOperator::GreaterThanOrEqual => match (left_type.deref(), right_type.deref()) { (VariableType::Date | VariableType::Any, VariableType::Date | VariableType::Any) => V(VariableType::Bool), (VariableType::Number | VariableType::Any, VariableType::Number | VariableType::Any) => V(VariableType::Bool), - (left, right) if date_string_pair(left, right) => V(VariableType::Bool), _ => Error(format!( "Operator `{operator}` cannot be applied to types `{left_type}` and `{right_type}`." )), @@ -426,8 +425,6 @@ impl TypesProvider { V(VariableType::Bool) }, (VariableType::Number | VariableType::Date, VariableType::Interval) => V(VariableType::Bool), - // The VM parses string subjects when the interval has date bounds. - (VariableType::String, VariableType::Interval) => V(VariableType::Bool), (VariableType::String, VariableType::Object(_)) => V(VariableType::Bool), (VariableType::Any, _) => V(VariableType::Bool), (_, VariableType::Any) => V(VariableType::Bool), @@ -669,7 +666,6 @@ impl TypesProvider { let needs_conversion = def.param_type(0) == Some(VariableType::Date) && (receiver.widen().is_string() || matches!(receiver, VariableType::Number)); if needs_conversion { - // `satisfies(Date)` also accepts constructor inputs. A receiver is already a value. self.set_error(this, "Date methods require a date value. Use d(...) to convert a date string or timestamp first.".to_string()); } for (i, arg_error) in typecheck.arguments { @@ -716,29 +712,12 @@ impl TypesProvider { fn types_disjoint(left: &VariableType, right: &VariableType) -> bool { let (left, _) = left.unwrap_nullable(); let (right, _) = right.unwrap_nullable(); - if date_string_pair(left, right) { - return false; - } match (value_set(left), value_set(right)) { (Some(l), Some(r)) => !l.iter().any(|v| r.contains(v)), _ => !left.satisfies(right), } } -/// Existing VM date/string comparisons parse the string, without changing its stored value. -fn date_string_pair(left: &VariableType, right: &VariableType) -> bool { - matches!( - (left, right), - ( - VariableType::Date, - VariableType::String | VariableType::Const(_) | VariableType::Enum(..) - ) | ( - VariableType::String | VariableType::Const(_) | VariableType::Enum(..), - VariableType::Date - ) - ) -} - fn value_set(t: &VariableType) -> Option>> { match t { VariableType::Const(c) => Some(vec![c.clone()]), diff --git a/core/expression/src/lexer/lexer.rs b/core/expression/src/lexer/lexer.rs index f3dfda43..1613dcda 100644 --- a/core/expression/src/lexer/lexer.rs +++ b/core/expression/src/lexer/lexer.rs @@ -14,7 +14,6 @@ pub struct Lexer { capacity_hint: usize, } -/// Tokens of a partial source: an unterminated string at EOF is closed with a zero-width quote. #[derive(Debug)] pub struct LenientTokens<'arena> { pub tokens: BumpVec<'arena, Token<'arena>>, diff --git a/core/expression/src/parser/parser.rs b/core/expression/src/parser/parser.rs index 4447938f..0b1eadca 100644 --- a/core/expression/src/parser/parser.rs +++ b/core/expression/src/parser/parser.rs @@ -1348,7 +1348,6 @@ 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/slot/classify.rs b/core/expression/src/slot/classify.rs index b852cf86..f8ab405b 100644 --- a/core/expression/src/slot/classify.rs +++ b/core/expression/src/slot/classify.rs @@ -8,14 +8,12 @@ use crate::lexer::{ ArithmeticOperator, Bracket, ComparisonOperator, Identifier, LogicalOperator, Operator, QuotationMark, TemplateString, Token, TokenKind, }; -use crate::parser::Parser; use crate::variable::VariableType; -use super::literals::{declared, item_expectation, NodeTable}; -use super::operators::{nullable_extras, operators_for, EQUALITY, LOGICAL}; +use super::literals::NodeTable; +use super::operators::Operators; use super::{ - enum_values, is_null_option, null_option, subject_enum_options, LabelResolver, Local, Parsed, - Slot, SlotRole, SlotState, Span, NULL_SOURCE, + LabelResolver, Local, Parsed, Slot, SlotRole, SlotState, Span, ValueOption, VariableTypeSlot, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -68,7 +66,7 @@ struct Frame { commas: u32, } -struct Ctx<'p, 'a> { +pub(crate) struct Classifier<'p, 'a> { parsed: Option<&'p Parsed<'a>>, table: Option<&'p NodeTable<'a>>, source: &'p str, @@ -80,102 +78,228 @@ struct Ctx<'p, 'a> { labels: Option<&'p LabelResolver>, } -#[allow(clippy::too_many_arguments)] -pub(crate) fn classify( - parsed: &Parsed, - table: &NodeTable, - pos: u32, - unary: bool, - role: SlotRole, - expected: Option<&VariableType>, - labels: Option<&LabelResolver>, -) -> Slot { - let (role, expected) = effective_role(parsed.source, pos, role, expected); - let ctx = Ctx::new(parsed, table, unary, role, expected, labels); - ctx.classify_at(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, - unary: bool, - role: SlotRole, - scope: &VariableType, - expected: Option<&VariableType>, - labels: Option<&LabelResolver>, -) -> Slot { - let (role, expected) = effective_role(source, pos, role, expected); - let ctx = Ctx { - parsed: None, - table: None, - source, - items: Vec::new(), - unary, - role, - scope, - expected, - labels, - }; - ctx.classify_at(pos) -} -fn effective_role<'p>( - source: &str, - pos: u32, - role: SlotRole, - expected: Option<&'p VariableType>, -) -> (SlotRole, Option<&'p VariableType>) { - if role != SlotRole::Path || is_path_prefix(source[..pos as usize].trim_start().as_bytes()) { - return (role, expected); +impl Item { + fn collect(tokens: &[Token]) -> Vec { + let mut items = Vec::with_capacity(tokens.len()); + let mut templates: Vec = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let t = &tokens[i]; + let single = Item { + first: i, + last: i, + span: t.span, + body: t.span, + kind: Kind::Ident, + }; + let kind = match t.kind { + TokenKind::QuotationMark( + q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), + ) => { + let closed = tokens + .get(i + 2) + .filter(|c| c.kind == TokenKind::QuotationMark(q)); + match (tokens.get(i + 1), closed) { + (Some(body), Some(close)) if body.kind == TokenKind::Literal => { + items.push(Item { + first: i, + last: i + 2, + span: (t.span.0, close.span.1), + body: body.span, + kind: Kind::Str { + quote: q, + open: close.span.0 == close.span.1, + }, + }); + i += 3; + continue; + } + _ => Kind::Str { + quote: q, + open: true, + }, + } + } + TokenKind::QuotationMark(QuotationMark::Backtick) => { + let zero_width = t.span.0 == t.span.1; + match templates.last() { + Some(false) => { + templates.pop(); + Kind::TemplateClose + } + Some(true) if zero_width => { + templates.pop(); + Kind::TemplateClose + } + _ => { + templates.push(false); + Kind::TemplateOpen + } + } + } + TokenKind::TemplateString(TemplateString::ExpressionStart) => { + if let Some(top) = templates.last_mut() { + *top = true; + } + Kind::ExprStart + } + TokenKind::TemplateString(TemplateString::ExpressionEnd) => { + if let Some(top) = templates.last_mut() { + *top = false; + } + Kind::ExprEnd + } + TokenKind::Literal => { + if templates.last() == Some(&false) { + Kind::TemplateText + } else { + Kind::Ident + } + } + TokenKind::Identifier(id) => Kind::Ref(id), + TokenKind::Boolean(_) => Kind::Bool, + TokenKind::Number => Kind::Number, + TokenKind::Bracket(b) => match b { + Bracket::LeftParenthesis + | Bracket::LeftSquareBracket + | Bracket::LeftCurlyBracket => Kind::Open(b), + _ => Kind::Close(b), + }, + TokenKind::Operator(op) => Kind::Op(op), + }; + items.push(Item { kind, ..single }); + i += 1; + } + items } - (SlotRole::Value, None) } -fn is_path_prefix(bytes: &[u8]) -> bool { - bytes.iter().all(|b| b.is_ascii_whitespace()) - || bytes.first().is_none_or(|b| is_ident_byte(*b)) - && bytes.iter().all(|b| is_ident_byte(*b) || *b == b'.') -} -pub(crate) struct Operands<'p, 'a> { - ctx: Ctx<'p, 'a>, +impl Kind { + fn is_operand_end(self) -> bool { + matches!( + self, + Kind::Str { .. } + | Kind::TemplateClose + | Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Close(_) + ) + } + + fn is_clause_boundary(self) -> bool { + matches!( + self, + Kind::Op(Operator::Logical(_)) + | Kind::Op(Operator::Comma) + | Kind::Op(Operator::QuestionMark) + | Kind::Op(Operator::Slice) + | Kind::Op(Operator::Assign) + | Kind::Op(Operator::Semi) + | Kind::Open(Bracket::LeftParenthesis) + | Kind::Open(Bracket::LeftCurlyBracket) + | Kind::ExprStart + ) + } + + fn is_word(self) -> bool { + matches!( + self, + Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Op(Operator::Logical( + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Not + )) + | Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn + )) + ) + } + + fn is_literal_start(self) -> bool { + matches!( + self, + Kind::Str { .. } + | Kind::TemplateOpen + | Kind::Number + | Kind::Bool + | Kind::Ref(Identifier::Null) + | Kind::Open(Bracket::LeftSquareBracket) + | Kind::Op(Operator::Arithmetic( + ArithmeticOperator::Add | ArithmeticOperator::Subtract + )) + ) + } } -impl<'p, 'a> Operands<'p, 'a> { - pub(crate) fn new( +impl<'p, 'a> Classifier<'p, 'a> { + pub(crate) fn classify( parsed: &'p Parsed<'a>, table: &'p NodeTable<'a>, + pos: u32, unary: bool, + role: SlotRole, expected: Option<&'p VariableType>, labels: Option<&'p LabelResolver>, - ) -> Self { - let ctx = Ctx::new(parsed, table, unary, SlotRole::Condition, expected, labels); - Self { ctx } + ) -> Slot { + let (role, expected) = Self::effective_role(parsed.source, pos, role, expected); + Self::new(parsed, table, unary, role, expected, labels).classify_at(pos) } + + pub(crate) fn fallback( + source: &'p str, + pos: u32, + unary: bool, + role: SlotRole, + scope: &'p VariableType, + expected: Option<&'p VariableType>, + labels: Option<&'p LabelResolver>, + ) -> Slot { + let (role, expected) = Self::effective_role(source, pos, role, expected); + let classifier = Self { + parsed: None, + table: None, + source, + items: Vec::new(), + unary, + role, + scope, + expected, + labels, + }; + classifier.classify_at(pos) + } + + pub(crate) fn closure_locals( + parsed: &'p Parsed<'a>, + table: &'p NodeTable<'a>, + pos: u32, + ) -> Vec<(Rc, VariableType)> { + let classifier = Self::new(parsed, table, false, SlotRole::Condition, None, None); + let limit = match classifier.string_at(pos) { + Some((idx, ..)) => idx, + None => match classifier.word_at(pos) { + Some(w) => classifier.not_in_head(w), + None => classifier.operand_limit(pos), + }, + }; + classifier.locals(limit) + } + pub(crate) fn expected_at(&self, start: u32) -> Option { - let ctx = &self.ctx; - let idx = ctx.items.iter().position(|it| it.span.0 == start)?; - if let Some(right) = ctx.comparison_after(idx) { - let end = ctx.run_end(right)?; - return ctx.operand_type(right, end).and_then(declared); + let idx = self.items.iter().position(|it| it.span.0 == start)?; + if let Some(right) = self.comparison_after(idx) { + let end = self.run_end(right)?; + return self + .operand_type(right, end) + .and_then(VariableTypeSlot::declared); } - let slot = ctx.at(start, idx); + let slot = self.at(start, idx); let method_arg = slot.state == SlotState::Argument - && ctx + && self .frames(idx) .last() .is_some_and(|f| matches!(f.kind, FrameKind::Call { method: true, .. })); @@ -184,184 +308,50 @@ impl<'p, 'a> Operands<'p, 'a> { } slot.expected } -} -fn build_items(tokens: &[Token]) -> Vec { - let mut items = Vec::with_capacity(tokens.len()); - let mut templates: Vec = Vec::new(); - let mut i = 0; - while i < tokens.len() { - let t = &tokens[i]; - let single = Item { - first: i, - last: i, - span: t.span, - body: t.span, - kind: Kind::Ident, - }; - let kind = match t.kind { - TokenKind::QuotationMark( - q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), - ) => { - let closed = tokens - .get(i + 2) - .filter(|c| c.kind == TokenKind::QuotationMark(q)); - match (tokens.get(i + 1), closed) { - (Some(body), Some(close)) if body.kind == TokenKind::Literal => { - items.push(Item { - first: i, - last: i + 2, - span: (t.span.0, close.span.1), - body: body.span, - kind: Kind::Str { - quote: q, - open: close.span.0 == close.span.1, - }, - }); - i += 3; - continue; - } - _ => Kind::Str { - quote: q, - open: true, - }, - } - } - TokenKind::QuotationMark(QuotationMark::Backtick) => { - let zero_width = t.span.0 == t.span.1; - match templates.last() { - Some(false) => { - templates.pop(); - Kind::TemplateClose - } - Some(true) if zero_width => { - templates.pop(); - Kind::TemplateClose - } - _ => { - templates.push(false); - Kind::TemplateOpen - } - } - } - TokenKind::TemplateString(TemplateString::ExpressionStart) => { - if let Some(top) = templates.last_mut() { - *top = true; - } - Kind::ExprStart - } - TokenKind::TemplateString(TemplateString::ExpressionEnd) => { - if let Some(top) = templates.last_mut() { - *top = false; - } - Kind::ExprEnd - } - TokenKind::Literal => { - if templates.last() == Some(&false) { - Kind::TemplateText - } else { - Kind::Ident - } - } - TokenKind::Identifier(id) => Kind::Ref(id), - TokenKind::Boolean(_) => Kind::Bool, - TokenKind::Number => Kind::Number, - TokenKind::Bracket(b) => match b { - Bracket::LeftParenthesis - | Bracket::LeftSquareBracket - | Bracket::LeftCurlyBracket => Kind::Open(b), - _ => Kind::Close(b), - }, - TokenKind::Operator(op) => Kind::Op(op), - }; - items.push(Item { kind, ..single }); - i += 1; + fn effective_role( + source: &str, + pos: u32, + role: SlotRole, + expected: Option<&'p VariableType>, + ) -> (SlotRole, Option<&'p VariableType>) { + if role != SlotRole::Path + || Self::is_path_prefix(source[..pos as usize].trim_start().as_bytes()) + { + return (role, expected); + } + (SlotRole::Value, None) } - items -} -fn is_operand_end(kind: Kind) -> bool { - matches!( - kind, - Kind::Str { .. } - | Kind::TemplateClose - | Kind::Ident - | Kind::Ref(_) - | Kind::Number - | Kind::Bool - | Kind::Close(_) - ) -} - -fn is_clause_boundary(kind: Kind) -> bool { - matches!( - kind, - Kind::Op(Operator::Logical(_)) - | Kind::Op(Operator::Comma) - | Kind::Op(Operator::QuestionMark) - | Kind::Op(Operator::Slice) - | Kind::Op(Operator::Assign) - | Kind::Op(Operator::Semi) - | Kind::Open(Bracket::LeftParenthesis) - | Kind::Open(Bracket::LeftCurlyBracket) - | Kind::ExprStart - ) -} - -fn is_word(kind: Kind) -> bool { - matches!( - kind, - Kind::Ident - | Kind::Ref(_) - | Kind::Number - | Kind::Bool - | Kind::Op(Operator::Logical( - LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Not - )) - | Kind::Op(Operator::Comparison( - ComparisonOperator::In | ComparisonOperator::NotIn - )) - ) -} - -fn is_literal_start(kind: Kind) -> bool { - matches!( - kind, - Kind::Str { .. } - | Kind::TemplateOpen - | Kind::Number - | Kind::Bool - | Kind::Ref(Identifier::Null) - | Kind::Open(Bracket::LeftSquareBracket) - | Kind::Op(Operator::Arithmetic( - ArithmeticOperator::Add | ArithmeticOperator::Subtract - )) - ) -} - -fn is_bool(t: &VariableType) -> bool { - matches!(t.unwrap_nullable().0, VariableType::Bool) -} -fn widen(t: VariableType) -> VariableType { - match t { - VariableType::Const(_) => VariableType::String, - other => other, + fn is_path_prefix(bytes: &[u8]) -> bool { + bytes.iter().all(|b| b.is_ascii_whitespace()) + || bytes.first().is_none_or(|b| Self::is_ident_byte(*b)) + && bytes.iter().all(|b| Self::is_ident_byte(*b) || *b == b'.') } -} -fn is_ordered(t: &VariableType) -> bool { - matches!( - t.unwrap_nullable().0, - VariableType::Number | VariableType::Date - ) -} + fn is_bool(t: &VariableType) -> bool { + matches!(t.unwrap_nullable().0, VariableType::Bool) + } -fn is_ident_byte(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'#' -} + fn widen(t: VariableType) -> VariableType { + match t { + VariableType::Const(_) => VariableType::String, + other => other, + } + } -impl<'p, 'a> Ctx<'p, 'a> { - fn new( + fn is_ordered(t: &VariableType) -> bool { + matches!( + t.unwrap_nullable().0, + VariableType::Number | VariableType::Date + ) + } + + fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'#' + } + + pub(crate) fn new( parsed: &'p Parsed<'a>, table: &'p NodeTable<'a>, unary: bool, @@ -373,7 +363,7 @@ impl<'p, 'a> Ctx<'p, 'a> { parsed: Some(parsed), table: Some(table), source: parsed.source, - items: build_items(parsed.tokens), + items: Item::collect(parsed.tokens), unary, role, scope: &parsed.scope, @@ -412,7 +402,7 @@ impl<'p, 'a> Ctx<'p, 'a> { } if arrays > 0 { for option in &mut slot.options { - if !is_null_option(option) { + if !option.is_null() { if let Some(source) = &mut option.source { *source = format!("{}{}{}", "[".repeat(arrays), source, "]".repeat(arrays)); @@ -437,14 +427,11 @@ impl<'p, 'a> Ctx<'p, 'a> { } = frame.kind { if frame.commas == 0 - && matches!( - FunctionKind::try_from(self.text(self.items[name].span)), - Ok(FunctionKind::Closure(_)) - ) + && self.is_closure(name) && self .collection_end(frame) .and_then(|end| self.items.get(end + 1)) - .is_some_and(|item| self.text(item.span) == "as" && item.span.1 < pos) + .is_some_and(|item| self.is_as(item) && item.span.1 < pos) { let span = self .word_at(pos) @@ -472,7 +459,7 @@ impl<'p, 'a> Ctx<'p, 'a> { { self.unlist(&mut slot, self.text(item.body)); } - slot.options.retain(|o| !is_null_option(o)); + slot.options.retain(|o| !o.is_null()); slot.operators.clear(); if !body.is_empty() { slot.options.retain(|o| { @@ -546,11 +533,11 @@ impl<'p, 'a> Ctx<'p, 'a> { fn path_slot(&self, pos: u32) -> Slot { let bytes = self.source.as_bytes(); let mut start = pos as usize; - while start > 0 && is_ident_byte(bytes[start - 1]) { + while start > 0 && Self::is_ident_byte(bytes[start - 1]) { start -= 1; } let mut end = pos as usize; - while end < bytes.len() && is_ident_byte(bytes[end]) { + while end < bytes.len() && Self::is_ident_byte(bytes[end]) { end += 1; } let mut slot = Slot::new(SlotState::Path, (start as u32, end as u32)); @@ -588,11 +575,9 @@ impl<'p, 'a> Ctx<'p, 'a> { slot.options = slot .operand .as_ref() - .and_then(|s| subject_enum_options(s, self.labels)) + .and_then(|s| ValueOption::for_type(s, self.labels)) .unwrap_or_default(); - let listed = std::mem::take(&mut slot.listed); - slot.options.retain(|o| !listed.contains(&o.value)); - slot.listed = listed; + slot.options.retain(|o| !slot.listed.contains(&o.value)); } fn operand_limit(&self, pos: u32) -> usize { self.items @@ -621,11 +606,7 @@ impl<'p, 'a> Ctx<'p, 'a> { else { continue; }; - let closure = matches!( - FunctionKind::try_from(self.text(self.items[name].span)), - Ok(FunctionKind::Closure(_)) - ); - if !closure || frame.commas == 0 { + if !self.is_closure(name) || frame.commas == 0 { continue; } let name: Rc = self @@ -648,10 +629,9 @@ impl<'p, 'a> Ctx<'p, 'a> { 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" => + _ if depth == 0 + && self.items[i - 1].kind != Kind::Op(Operator::Dot) + && self.is_as(&self.items[i]) => { return Some(i - 1); } @@ -663,7 +643,7 @@ impl<'p, 'a> Ctx<'p, 'a> { 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" { + if !self.is_as(as_word) { return None; } let alias = self @@ -676,7 +656,7 @@ impl<'p, 'a> Ctx<'p, 'a> { fn word_at(&self, pos: u32) -> Option { self.items .iter() - .position(|it| it.span.0 < pos && pos <= it.span.1 && is_word(it.kind)) + .position(|it| it.span.0 < pos && pos <= it.span.1 && it.kind.is_word()) } fn string_at(&self, pos: u32) -> Option<(usize, char, Span)> { @@ -772,7 +752,7 @@ impl<'p, 'a> Ctx<'p, 'a> { } Kind::Open(Bracket::LeftSquareBracket) => { let kind = match before { - Some(k) if is_operand_end(k) => FrameKind::Index, + Some(k) if k.is_operand_end() => FrameKind::Index, Some(Kind::Op(Operator::Comparison( ComparisonOperator::In | ComparisonOperator::NotIn, ))) => FrameKind::List { @@ -783,7 +763,7 @@ impl<'p, 'a> Ctx<'p, 'a> { after_in: None, implicit: self.unary && frames.is_empty() - && other.is_none_or(is_clause_boundary), + && other.is_none_or(Kind::is_clause_boundary), }, }; frames.push(Frame { @@ -867,7 +847,7 @@ impl<'p, 'a> Ctx<'p, 'a> { let unary_sign = sign && !p .checked_sub(1) - .is_some_and(|k| is_operand_end(self.items[k].kind)); + .is_some_and(|k| self.items[k].kind.is_operand_end()); if !unary_sign { break; } @@ -915,17 +895,13 @@ impl<'p, 'a> Ctx<'p, 'a> { ComparisonOperator::In | ComparisonOperator::NotIn, ))) = before { - let subject = if self.implicit_subject(p - 1) { - Some(self.subject()) - } else { - self.left_operand(p - 1) - }; + let subject = self.comparison_left(p - 1); return self.range_slot(None, subject); } let unary_interval = self.unary && frames.len() == 1 - && before.is_none_or(is_clause_boundary) - && is_ordered(&self.subject()); + && before.is_none_or(Kind::is_clause_boundary) + && Self::is_ordered(&self.subject()); if unary_interval { return self.range_slot(None, Some(self.subject())); } @@ -957,19 +933,11 @@ impl<'p, 'a> Ctx<'p, 'a> { Kind::Op(Operator::Logical(LogicalOperator::And | LogicalOperator::Or)) => { if frames.is_empty() && self.unary { self.unary_start() - } else if self.closure_frame(frames).is_some() { - self.closure_head(frames) } else { - self.start(Some(VariableType::Bool)) - } - } - Kind::Op(Operator::Logical(LogicalOperator::Not)) => { - if self.closure_frame(frames).is_some() { self.closure_head(frames) - } else { - self.start(Some(VariableType::Bool)) } } + Kind::Op(Operator::Logical(LogicalOperator::Not)) => self.closure_head(frames), Kind::Op(Operator::Logical(LogicalOperator::NullishCoalescing)) => { let left = self .left_operand(p) @@ -985,7 +953,7 @@ impl<'p, 'a> Ctx<'p, 'a> { }, Kind::Op(Operator::Semi) => self.start(self.whole_expected(frames, limit)), Kind::Op(Operator::Arithmetic(_)) => { - let left = self.left_operand(p).map(widen); + let left = self.left_operand(p).map(Self::widen); self.start(left) } Kind::Op(Operator::Assign) @@ -994,7 +962,7 @@ impl<'p, 'a> Ctx<'p, 'a> { | Kind::TemplateOpen | Kind::TemplateText | Kind::ExprEnd => self.start(None), - kind if is_operand_end(kind) => self.after_operand(p, gap, frames), + kind if kind.is_operand_end() => self.after_operand(p, gap, frames), _ => self.start(None), } } @@ -1005,7 +973,7 @@ impl<'p, 'a> Ctx<'p, 'a> { match frame.kind { FrameKind::Paren if self.branch_paren(frame.open) => {} FrameKind::List { after_in: None, .. } => { - current = item_expectation(Some(¤t))?; + current = current.innermost().shallow_clone(); } FrameKind::Object => { let key = self.object_key(frame.open, end)?; @@ -1070,7 +1038,7 @@ impl<'p, 'a> Ctx<'p, 'a> { fn branch_slot(&self, expected: Option) -> Slot { let enumerable = expected .as_ref() - .is_some_and(|e| subject_enum_options(e, self.labels).is_some()); + .is_some_and(|e| ValueOption::for_type(e, self.labels).is_some()); if expected.is_some() && (self.role == SlotRole::Value || enumerable) { self.value_slot(expected, None) } else { @@ -1079,12 +1047,7 @@ impl<'p, 'a> Ctx<'p, 'a> { } fn subject(&self) -> VariableType { - // A declared date format guides implicit unary values; explicit reads of $ - // still use its runtime type (a string until converted with d(...)). - self.expected - .filter(|hint| self.unary && super::is_date_type(hint)) - .map(VariableType::shallow_clone) - .unwrap_or_else(|| self.scope.get("$")) + self.scope.get("$") } fn head(&self) -> Slot { @@ -1109,10 +1072,10 @@ impl<'p, 'a> Ctx<'p, 'a> { let mut slot = Slot::new(SlotState::Value, (0, 0)); slot.options = expected .as_ref() - .and_then(|e| subject_enum_options(e, self.labels)) + .and_then(|e| ValueOption::for_type(e, self.labels)) .unwrap_or_default(); if matches!(expected, Some(VariableType::Nullable(_))) { - slot.options.push(null_option()); + slot.options.push(ValueOption::null()); } slot.expected = expected; slot.operand = operand; @@ -1124,14 +1087,14 @@ impl<'p, 'a> Ctx<'p, 'a> { let subject = self.subject(); let mut slot = Slot::new(SlotState::UnaryStart, (0, 0)); slot.listed = self.unary_listed(); - slot.options = subject_enum_options(&subject, self.labels).unwrap_or_default(); + slot.options = ValueOption::for_type(&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.listed.iter().any(|v| v == ValueOption::NULL) { - slot.options.push(null_option()); + slot.options.push(ValueOption::null()); } - slot.operators = operators_for(&subject, true); + slot.operators = Operators::for_type(&subject, true); slot.auto_open = !slot.options.is_empty() || !slot.operators.is_empty(); slot.expected = Some(subject.shallow_clone()); slot.operand = Some(subject); @@ -1139,7 +1102,7 @@ impl<'p, 'a> Ctx<'p, 'a> { } fn left_operand(&self, op: usize) -> Option { let end = op.checked_sub(1)?; - if !is_operand_end(self.items[end].kind) { + if !self.items[end].kind.is_operand_end() { return None; } let start = self.run_start(end, true); @@ -1148,14 +1111,33 @@ impl<'p, 'a> Ctx<'p, 'a> { fn operand_type(&self, start: usize, end: usize) -> Option { let t = self.type_of_run(start, end, true)?; let literal = start == end && matches!(self.items[start].kind, Kind::Str { .. }); - Some(if literal { widen(t) } else { t }) + Some(if literal { Self::widen(t) } else { t }) + } + + fn comparison_left(&self, op: usize) -> Option { + if self.implicit_subject(op) { + Some(self.subject()) + } else { + self.left_operand(op) + } + } + + fn is_closure(&self, name: usize) -> bool { + matches!( + FunctionKind::try_from(self.text(self.items[name].span)), + Ok(FunctionKind::Closure(_)) + ) + } + + fn is_as(&self, item: &Item) -> bool { + item.kind == Kind::Ident && self.text(item.span) == "as" } fn implicit_subject(&self, op: usize) -> bool { self.unary && op .checked_sub(1) - .is_none_or(|k| !is_operand_end(self.items[k].kind)) + .is_none_or(|k| !self.items[k].kind.is_operand_end()) } fn operator_prefix(&self, p: usize) -> Option { if !matches!( @@ -1165,18 +1147,13 @@ impl<'p, 'a> Ctx<'p, 'a> { ) { 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) - }; + let operand = self.comparison_left(p); let operators: Vec<&'static str> = - operators_for(operand.as_ref().unwrap_or(&VariableType::Any), false) + Operators::for_type(operand.as_ref().unwrap_or(&VariableType::Any), false) .into_iter() .filter(|op| op.starts_with(typed)) .collect(); @@ -1191,11 +1168,7 @@ impl<'p, 'a> Ctx<'p, 'a> { } fn value_after(&self, p: usize, op: ComparisonOperator) -> Slot { - let left = if self.implicit_subject(p) { - Some(self.subject()) - } else { - self.left_operand(p) - }; + let left = self.comparison_left(p); let expected = match (op, &left) { (ComparisonOperator::In | ComparisonOperator::NotIn, Some(t)) => { Some(VariableType::Array(Rc::new(t.shallow_clone()))) @@ -1204,7 +1177,7 @@ impl<'p, 'a> Ctx<'p, 'a> { }; let mut slot = self.value_slot(expected, left); if !matches!(op, ComparisonOperator::Equal | ComparisonOperator::NotEqual) { - slot.options.retain(|o| !is_null_option(o)); + slot.options.retain(|o| !o.is_null()); } slot } @@ -1213,7 +1186,7 @@ impl<'p, 'a> Ctx<'p, 'a> { let mut slot = Slot::new(SlotState::Member, (0, 0)); if let Some(end) = p .checked_sub(1) - .filter(|&k| is_operand_end(self.items[k].kind)) + .filter(|&k| self.items[k].kind.is_operand_end()) { let start = self.run_start(end, false); slot.operand = self.type_of_run(start, end, true); @@ -1229,18 +1202,12 @@ impl<'p, 'a> Ctx<'p, 'a> { match frame.kind { FrameKind::List { after_in: Some(op), .. - } => { - if self.implicit_subject(op) { - Some(self.subject()) - } else { - self.left_operand(op) - } - } + } => self.comparison_left(op), FrameKind::List { implicit: true, .. } => Some(self.subject()), FrameKind::List { .. } => { let before = frame.open.checked_sub(1).map(|k| self.items[k].kind); match before { - None => item_expectation(self.expected), + None => self.expected.map(|t| t.innermost().shallow_clone()), Some(Kind::Op(Operator::Comparison(_))) => self.left_operand(frame.open - 1), Some(Kind::Open(Bracket::LeftParenthesis)) => self .closure_membership(frame.open) @@ -1254,26 +1221,22 @@ impl<'p, 'a> Ctx<'p, 'a> { fn list_expected(&self, frame: &Frame) -> Option { let head = self.at(self.items[frame.open].span.0, frame.open); - item_expectation(head.expected.as_ref()).filter(|t| !matches!(t, VariableType::Any)) + head.expected + .map(|t| t.innermost().shallow_clone()) + .filter(|t| !matches!(t, VariableType::Any)) } fn closure_membership(&self, open: usize) -> Option { let paren = open.checked_sub(1)?; let name = paren.checked_sub(1)?; let closure = self.items[paren].kind == Kind::Open(Bracket::LeftParenthesis) && self.items[name].kind == Kind::Ident - && matches!( - FunctionKind::try_from(self.text(self.items[name].span)), - Ok(FunctionKind::Closure(_)) - ); + && self.is_closure(name); if !closure { return None; } let mut i = self.matched_close(open)? + 1; let mut alias = None; - let as_word = self - .items - .get(i) - .is_some_and(|it| it.kind == Kind::Ident && self.text(it.span) == "as"); + let as_word = self.items.get(i).is_some_and(|it| self.is_as(it)); if as_word { alias = Some(self.text(self.items.get(i + 1)?.span)); i += 2; @@ -1299,7 +1262,8 @@ impl<'p, 'a> Ctx<'p, 'a> { } let end = self.run_end(right)?; let rhs = self.type_of_run(right, end, true)?; - enum_values(&rhs).map(|(name, values)| VariableType::Enum(name, values)) + rhs.enum_domain() + .map(|(name, values)| VariableType::Enum(name, values)) } fn matched_close(&self, open: usize) -> Option { @@ -1327,7 +1291,7 @@ impl<'p, 'a> Ctx<'p, 'a> { return self.start(Some(VariableType::Number)); } let subject = self.list_subject(frame); - if let Some(range) = self.range_ahead(limit, frame.open) { + if let Some(range) = self.range_ahead(limit) { let partner = self .run_end(range + 1) .and_then(|end| self.type_of_run(range + 1, end, true)); @@ -1338,10 +1302,10 @@ impl<'p, 'a> Ctx<'p, 'a> { slot.listed = self.list_listed(frame.open); slot.options = subject .as_ref() - .and_then(|s| subject_enum_options(s, self.labels)) + .and_then(|s| ValueOption::for_type(s, self.labels)) .unwrap_or_default(); slot.options.retain(|o| !slot.listed.contains(&o.value)); - slot.expected = subject.as_ref().and_then(|s| item_expectation(Some(s))); + slot.expected = subject.as_ref().map(|s| s.innermost().shallow_clone()); slot.operand = subject; slot.auto_open = true; slot @@ -1355,21 +1319,14 @@ impl<'p, 'a> Ctx<'p, 'a> { fn range_slot(&self, bound: Option, subject: Option) -> Slot { let mut slot = Slot::new(SlotState::Range, (0, 0)); - let pick = |t: Option| { - t.filter(|t| { - matches!( - t.unwrap_nullable().0, - VariableType::Number | VariableType::Date - ) - }) - }; + let pick = |t: Option| t.filter(Self::is_ordered); slot.expected = pick(bound) .or_else(|| pick(subject)) .or(Some(VariableType::Number)); slot } - fn range_ahead(&self, limit: usize, open: usize) -> Option { + fn range_ahead(&self, limit: usize) -> Option { let mut depth = 0; for (i, it) in self.items.iter().enumerate().skip(limit) { match it.kind { @@ -1385,11 +1342,10 @@ impl<'p, 'a> Ctx<'p, 'a> { _ => {} } } - let _ = open; None } - fn closure_frame<'f>(&self, frames: &'f [Frame]) -> Option<&'f Frame> { + fn closure_frame<'f>(&self, frames: &'f [Frame]) -> Option<(&'f Frame, usize)> { for frame in frames.iter().rev() { match frame.kind { FrameKind::Paren => continue, @@ -1397,11 +1353,7 @@ impl<'p, 'a> Ctx<'p, 'a> { name, method: false, } => { - let closure = matches!( - FunctionKind::try_from(self.text(self.items[name].span)), - Ok(FunctionKind::Closure(_)) - ); - return (closure && frame.commas >= 1).then_some(frame); + return (self.is_closure(name) && frame.commas >= 1).then_some((frame, name)); } _ => return None, } @@ -1424,17 +1376,14 @@ impl<'p, 'a> Ctx<'p, 'a> { } fn closure_head(&self, frames: &[Frame]) -> Slot { - let Some(frame) = self.closure_frame(frames) else { + let Some((frame, name)) = self.closure_frame(frames) else { return self.start(Some(VariableType::Bool)); }; - let FrameKind::Call { name, .. } = frame.kind else { - return self.start(None); - }; let element = self.element_type(frame); let mut slot = Slot::new(SlotState::Closure, (0, 0)); slot.options = element .as_ref() - .and_then(|e| subject_enum_options(e, self.labels)) + .and_then(|e| ValueOption::for_type(e, self.labels)) .unwrap_or_default(); slot.expected = element.clone(); slot.operand = element; @@ -1465,7 +1414,7 @@ impl<'p, 'a> Ctx<'p, 'a> { .and_then(|d| d.param_type(index as usize + 1)); if let Some(end) = name .checked_sub(2) - .filter(|&k| is_operand_end(self.items[k].kind)) + .filter(|&k| self.items[k].kind.is_operand_end()) { let start = self.run_start(end, false); slot.operand = self.type_of_run(start, end, true); @@ -1491,7 +1440,7 @@ impl<'p, 'a> Ctx<'p, 'a> { slot.options = slot .expected .as_ref() - .and_then(|e| subject_enum_options(e, self.labels)) + .and_then(|e| ValueOption::for_type(e, self.labels)) .unwrap_or_default(); slot } @@ -1508,12 +1457,12 @@ impl<'p, 'a> Ctx<'p, 'a> { let start = self.run_start(p, true); let before = start.checked_sub(1).map(|k| self.items[k].kind); let operand = self.type_of_run(start, p, true); - let at_clause = before.is_none_or(is_clause_boundary); + let at_clause = before.is_none_or(Kind::is_clause_boundary); let literal_clause = self.unary && frames.is_empty() && at_clause - && is_literal_start(self.items[start].kind); - let bool_clause = at_clause && operand.as_ref().is_some_and(is_bool); + && self.items[start].kind.is_literal_start(); + let bool_clause = at_clause && operand.as_ref().is_some_and(Self::is_bool); let date_clause = self.unary && frames.is_empty() && at_clause @@ -1526,10 +1475,11 @@ impl<'p, 'a> Ctx<'p, 'a> { if after_comparison || literal_clause || bool_clause || date_clause { let mut slot = Slot::new(SlotState::Logical, (0, 0)); slot.expected = Some(VariableType::Bool); - slot.operators = LOGICAL.to_vec(); + slot.operators = Operators::LOGICAL.to_vec(); let nullable = !after_comparison && matches!(operand, Some(VariableType::Nullable(_))); if nullable { - slot.operators.extend(nullable_extras(self.unary)); + slot.operators + .extend(Operators::nullable_extras(self.unary)); } else if bool_clause && !after_comparison && !matches!( @@ -1538,14 +1488,14 @@ impl<'p, 'a> Ctx<'p, 'a> { ) && self.is_field_run(start, p) { - slot.operators.extend(EQUALITY); + slot.operators.extend(Operators::EQUALITY); } slot.auto_open = gap; return slot; } let mut slot = Slot::new(SlotState::Operator, (0, 0)); - slot.operators = operators_for(operand.as_ref().unwrap_or(&VariableType::Any), false); + slot.operators = Operators::for_type(operand.as_ref().unwrap_or(&VariableType::Any), false); slot.operand = operand; slot.auto_open = gap && !slot.operators.is_empty(); slot @@ -1602,7 +1552,7 @@ impl<'p, 'a> Ctx<'p, 'a> { }; let binary = before .checked_sub(1) - .is_some_and(|k| is_operand_end(self.items[k].kind)); + .is_some_and(|k| self.items[k].kind.is_operand_end()); if binary { start = self.primary_start(before - 1); continue; @@ -1662,30 +1612,20 @@ impl<'p, 'a> Ctx<'p, 'a> { return Some(parsed.type_of(node)); } - let tokens = &parsed.tokens[self.items[start].first..=self.items[end].last]; - let result = Parser::try_new(tokens, parsed.arena) - .ok()? - .standard() - .parse(); - if !result.is_complete || result.root.has_error() { - return None; - } + let root = parsed.reparse(self.items[start].first, self.items[end].last)?; let pointer = if with_pointer { self.pointer_type(start) } else { None }; - let types = TypesProvider::generate( - result.root, - parsed.intellisense_scope(pointer), - parsed.strict, - ); - types.get_type(result.root).map(|t| t.kind.clone()) + let types = + TypesProvider::generate(root, parsed.intellisense_scope(pointer), parsed.strict); + types.get_type(root).map(|t| t.kind.clone()) } fn pointer_type(&self, at: usize) -> Option { let frames = self.frames(at); - let frame = self.closure_frame(&frames)?; + let (frame, _) = self.closure_frame(&frames)?; self.element_type(frame) } @@ -1704,7 +1644,9 @@ impl<'p, 'a> Ctx<'p, 'a> { Kind::Str { open: false, .. } if depth == 0 => { listed.push(self.text(it.body).to_string()); } - Kind::Ref(Identifier::Null) if depth == 0 => listed.push(NULL_SOURCE.to_string()), + Kind::Ref(Identifier::Null) if depth == 0 => { + listed.push(ValueOption::NULL.to_string()) + } _ => {} } } @@ -1722,7 +1664,7 @@ impl<'p, 'a> Ctx<'p, 'a> { if depth == 0 { list_open = b == Bracket::LeftSquareBracket && before.is_none_or(|k| { - is_clause_boundary(k) + k.is_clause_boundary() || matches!( k, Kind::Op(Operator::Comparison( @@ -1743,12 +1685,12 @@ impl<'p, 'a> Ctx<'p, 'a> { Kind::Str { open: false, .. } | Kind::Ref(Identifier::Null) => { let clause_value = depth == 0 && before.is_none_or(|k| { - is_clause_boundary(k) + k.is_clause_boundary() || k == Kind::Op(Operator::Comparison(ComparisonOperator::Equal)) }); if clause_value || (depth == 1 && list_open) { listed.push(if it.kind == Kind::Ref(Identifier::Null) { - NULL_SOURCE.to_string() + ValueOption::NULL.to_string() } else { self.text(it.body).to_string() }); diff --git a/core/expression/src/slot/literals.rs b/core/expression/src/slot/literals.rs index 6f10a6cc..2676f6cd 100644 --- a/core/expression/src/slot/literals.rs +++ b/core/expression/src/slot/literals.rs @@ -8,23 +8,25 @@ use crate::lexer::{ ArithmeticOperator, Bracket, ComparisonOperator, LogicalOperator, Operator, QuotationMark, Token, TokenKind, }; -use crate::parser::{Node, Parser}; +use crate::parser::Node; use crate::variable::VariableType; use crate::vm::VmDate; use crate::Variable; -use super::classify::Operands; +use super::classify::Classifier; use super::{ - enum_options, enum_values, DateArg, EnumTable, LabelResolver, LiteralFact, Parsed, Span, + DateArg, EnumTable, LabelResolver, LiteralFact, Literals, Parsed, SlotRole, Span, ValueOption, + VariableTypeSlot, }; -pub(crate) struct Entry<'a> { - pub node: &'a Node<'a>, - pub span: Span, - pub expected: Option, +struct Entry<'a> { + node: &'a Node<'a>, + span: Span, + expected: Option, } + pub(crate) struct NodeTable<'a> { - pub entries: Vec>, + entries: Vec>, } impl<'a> NodeTable<'a> { @@ -41,9 +43,6 @@ impl<'a> NodeTable<'a> { while let Some((node, expected)) = stack.pop() { let mut children: Vec<(&'a Node<'a>, Option)> = Vec::new(); - let push = |children: &mut Vec<(&'a Node<'a>, Option)>, - n: &'a Node<'a>, - e: Option| children.push((n, e)); match node { Node::Binary { @@ -53,8 +52,8 @@ impl<'a> NodeTable<'a> { } => { let (left_expected, right_expected) = match operator { Operator::Comparison(_) => ( - comparison_operand(parsed, right), - comparison_operand(parsed, left), + Self::comparison_operand(parsed, right), + Self::comparison_operand(parsed, left), ), Operator::Logical(LogicalOperator::And | LogicalOperator::Or) => { (Some(VariableType::Bool), Some(VariableType::Bool)) @@ -62,20 +61,23 @@ impl<'a> NodeTable<'a> { Operator::Logical(LogicalOperator::NullishCoalescing) => { let fallback = expected .as_ref() - .filter(|e| enum_values(e).is_some()) + .filter(|e| e.enum_domain().is_some()) .cloned() .or_else(|| { - declared( - parsed.type_of(left).unwrap_nullable().0.shallow_clone(), - ) + parsed + .type_of(left) + .unwrap_nullable() + .0 + .shallow_clone() + .declared() }) .or_else(|| expected.clone()); (expected.clone(), fallback) } _ => (None, None), }; - push(&mut children, left, left_expected); - push(&mut children, right, right_expected); + children.push((left, left_expected)); + children.push((right, right_expected)); } Node::Unary { node: inner, @@ -88,34 +90,34 @@ impl<'a> NodeTable<'a> { ) => Some(VariableType::Number), _ => None, }; - push(&mut children, inner, inner_expected); + children.push((inner, inner_expected)); } Node::Conditional { condition, on_true, on_false, } => { - push(&mut children, condition, Some(VariableType::Bool)); - push(&mut children, on_true, expected.clone()); - push(&mut children, on_false, expected.clone()); + children.push((condition, Some(VariableType::Bool))); + children.push((on_true, expected.clone())); + children.push((on_false, expected.clone())); } Node::Interval { left, right, .. } => { - push(&mut children, left, expected.clone()); - push(&mut children, right, expected.clone()); + children.push((left, expected.clone())); + children.push((right, expected.clone())); } Node::Array(items) => { - let item_expected = item_expectation(expected.as_ref()); + let item_expected = expected.as_ref().map(|t| t.innermost().shallow_clone()); for item in items.iter() { - push(&mut children, item, item_expected.clone()); + children.push((item, item_expected.clone())); } } - Node::Parenthesized(inner) => push(&mut children, inner, expected.clone()), - Node::Closure { body, .. } => push(&mut children, body, expected.clone()), + Node::Parenthesized(inner) => children.push((inner, expected.clone())), + Node::Closure { body, .. } => children.push((body, expected.clone())), Node::FunctionCall { kind, arguments } => { let expectations = - function_expectations(parsed, kind, arguments, expected.as_ref()); + Self::function_expectations(parsed, kind, arguments, expected.as_ref()); for (i, arg) in arguments.iter().enumerate() { - push(&mut children, arg, expectations.get(i).cloned().flatten()); + children.push((arg, expectations.get(i).cloned().flatten())); } } Node::MethodCall { @@ -123,58 +125,54 @@ impl<'a> NodeTable<'a> { this, arguments, } => { - push(&mut children, this, None); - let first = method_expects_date(kind).then_some(VariableType::Date); + children.push((this, None)); + let first = Self::method_expects_date(kind).then_some(VariableType::Date); for (i, arg) in arguments.iter().enumerate() { - push( - &mut children, - arg, - if i == 0 { first.clone() } else { None }, - ); + children.push((arg, if i == 0 { first.clone() } else { None })); } } Node::Member { node, property } => { - push(&mut children, node, None); - push(&mut children, property, None); + children.push((node, None)); + children.push((property, None)); } Node::Slice { node, from, to } => { - push(&mut children, node, None); + children.push((node, None)); if let Some(from) = from { - push(&mut children, from, None); + children.push((from, None)); } if let Some(to) = to { - push(&mut children, to, None); + children.push((to, None)); } } Node::Object(pairs) => { for (k, v) in pairs.iter() { let field = match (expected.as_ref(), k) { (Some(e), Node::String(key) | Node::Identifier(key)) => { - declared(e.get(key)) + e.get(key).declared() } _ => None, }; - push(&mut children, k, None); - push(&mut children, v, field); + children.push((k, None)); + children.push((v, field)); } } Node::Assignments { list, output } => { for (k, v) in list.iter() { - push(&mut children, k, None); - push(&mut children, v, None); + children.push((k, None)); + children.push((v, None)); } if let Some(output) = output { - push(&mut children, output, expected.clone()); + children.push((output, expected.clone())); } } Node::TemplateString(parts) => { for part in parts.iter() { - push(&mut children, part, None); + children.push((part, None)); } } Node::Error { node: inner, .. } => { if let Some(inner) = inner { - push(&mut children, inner, expected.clone()); + children.push((inner, expected.clone())); } } Node::Null @@ -202,144 +200,109 @@ impl<'a> NodeTable<'a> { .rfind(|e| e.span == span && !matches!(e.node, Node::Error { .. })) .map(|e| e.node) } -} -fn comparison_operand(parsed: &Parsed, node: &Node) -> Option { - match (node, parsed.type_of(node)) { - (Node::String(_), VariableType::Const(_)) => Some(VariableType::String), - (_, t) => declared(t), - } -} -pub(crate) fn declared(t: VariableType) -> Option { - match t { - VariableType::Any => None, - other => Some(other), - } -} - -pub(crate) fn item_expectation(t: Option<&VariableType>) -> Option { - let mut current = t?; - loop { - match current { - VariableType::Array(inner) | VariableType::Nullable(inner) => current = inner, - other => return Some(other.shallow_clone()), + fn comparison_operand(parsed: &Parsed, node: &Node) -> Option { + match (node, parsed.type_of(node)) { + (Node::String(_), VariableType::Const(_)) => Some(VariableType::String), + (_, t) => t.declared(), } } -} -fn method_expects_date(kind: &MethodKind) -> bool { - matches!( - kind, - MethodKind::DateMethod( - DateMethod::IsSame - | DateMethod::IsBefore - | DateMethod::IsAfter - | DateMethod::IsSameOrBefore - | DateMethod::IsSameOrAfter - | DateMethod::Diff + fn method_expects_date(kind: &MethodKind) -> bool { + matches!( + kind, + MethodKind::DateMethod( + DateMethod::IsSame + | DateMethod::IsBefore + | DateMethod::IsAfter + | DateMethod::IsSameOrBefore + | DateMethod::IsSameOrAfter + | DateMethod::Diff + ) ) - ) -} + } -fn function_expectations( - parsed: &Parsed, - kind: &FunctionKind, - arguments: &[&Node], - expected: Option<&VariableType>, -) -> Vec> { - match kind { - FunctionKind::Closure(cf) => { - let body_expected = match cf { - ClosureFunction::Map | ClosureFunction::FlatMap => None, - _ => Some(VariableType::Bool), - }; - vec![closure_membership(parsed, arguments), body_expected] - } - FunctionKind::Internal(InternalFunction::Date) => vec![Some(VariableType::Date)], - FunctionKind::Internal(InternalFunction::Bool) => vec![expected.map(|e| e.shallow_clone())], - FunctionKind::Internal( - InternalFunction::Contains - | InternalFunction::StartsWith - | InternalFunction::EndsWith - | InternalFunction::Matches - | InternalFunction::FuzzyMatch, - ) => { - let needle = match arguments.first().map(|a| parsed.type_of(a)) { - Some(haystack @ VariableType::Array(_)) => Some(haystack), - _ => None, - }; - vec![None, needle] - } - _ => Vec::new(), - } -} -fn closure_membership(parsed: &Parsed, arguments: &[&Node]) -> Option { - let Some(Node::Closure { body, alias }) = arguments.get(1) else { - return None; - }; - let mut node: &Node = body; - while let Node::Parenthesized(inner) = node { - node = inner; - } - let Node::Binary { - left, - operator: Operator::Comparison(ComparisonOperator::In | ComparisonOperator::NotIn), - right, - } = node - else { - return None; - }; - let binds = match left { - Node::Pointer => alias.is_none(), - Node::Identifier(name) => *alias == Some(*name), - _ => false, - }; - if !binds { - return None; - } - let rhs = parsed.type_of(right); - enum_values(&rhs).map(|(name, values)| VariableType::Enum(name, values)) -} - -pub(crate) fn field_path(node: &Node) -> Option> { - match node { - Node::Identifier(name) => Some(vec![name.to_string()]), - Node::Pointer => Some(vec!["#".to_string()]), - Node::Root => Some(vec!["$root".to_string()]), - Node::Member { node, property } => { - let mut base = field_path(node)?; - match property { - Node::String(s) => base.push(s.to_string()), - Node::Root => base.push("$root".to_string()), - Node::Number(n) if n.is_integer() && !n.is_sign_negative() => { - let last = base.pop()?; - base.push(format!("{last}[{n}]")); - } - _ => return None, + fn function_expectations( + parsed: &Parsed, + kind: &FunctionKind, + arguments: &[&Node], + expected: Option<&VariableType>, + ) -> Vec> { + match kind { + FunctionKind::Closure(cf) => { + let body_expected = match cf { + ClosureFunction::Map | ClosureFunction::FlatMap => None, + _ => Some(VariableType::Bool), + }; + vec![Self::closure_membership(parsed, arguments), body_expected] } - Some(base) + FunctionKind::Internal(InternalFunction::Date) => vec![Some(VariableType::Date)], + FunctionKind::Internal(InternalFunction::Bool) => { + vec![expected.map(|e| e.shallow_clone())] + } + FunctionKind::Internal( + InternalFunction::Contains + | InternalFunction::StartsWith + | InternalFunction::EndsWith + | InternalFunction::Matches + | InternalFunction::FuzzyMatch, + ) => { + let needle = match arguments.first().map(|a| parsed.type_of(a)) { + Some(haystack @ VariableType::Array(_)) => Some(haystack), + _ => None, + }; + vec![None, needle] + } + _ => Vec::new(), } - _ => None, + } + + fn closure_membership(parsed: &Parsed, arguments: &[&Node]) -> Option { + let Some(Node::Closure { body, alias }) = arguments.get(1) else { + return None; + }; + let mut node: &Node = body; + while let Node::Parenthesized(inner) = node { + node = inner; + } + let Node::Binary { + left, + operator: Operator::Comparison(ComparisonOperator::In | ComparisonOperator::NotIn), + right, + } = node + else { + return None; + }; + let binds = match left { + Node::Pointer => alias.is_none(), + Node::Identifier(name) => *alias == Some(*name), + _ => false, + }; + if !binds { + return None; + } + let rhs = parsed.type_of(right); + rhs.enum_domain() + .map(|(name, values)| VariableType::Enum(name, values)) } } - -pub(crate) struct EnumInterner<'l> { - pub tables: Vec, +struct EnumInterner<'l> { + tables: Vec, labels: Option<&'l LabelResolver>, } impl<'l> EnumInterner<'l> { - pub(crate) fn new(labels: Option<&'l LabelResolver>) -> Self { + fn new(labels: Option<&'l LabelResolver>) -> Self { Self { tables: Vec::new(), labels, } } - pub(crate) fn intern(&mut self, name: Option<&Rc>, values: &[Rc]) -> u32 { + fn intern(&mut self, name: Option<&Rc>, values: &[Rc]) -> u32 { let table = EnumTable { name: name.map(|n| n.to_string()), - options: enum_options(name.map(|n| n.as_ref()), values, self.labels), + options: ValueOption::enum_list(name.map(|n| n.as_ref()), values, self.labels), }; if let Some(index) = self.tables.iter().position(|t| *t == table) { return index as u32; @@ -347,73 +310,25 @@ impl<'l> EnumInterner<'l> { self.tables.push(table); (self.tables.len() - 1) as u32 } -} -impl<'l> EnumInterner<'l> { fn enum_fact( &mut self, span: Span, value: &str, expected: Option<&VariableType>, ) -> Option { - let (name, values) = expected.and_then(enum_values)?; - let label = self - .labels - .zip(name.as_deref()) - .and_then(|(resolve, n)| resolve(n, value)) - .filter(|l| !l.is_empty()) - .unwrap_or_else(|| value.to_string()); + let (name, values) = expected?.enum_domain()?; Some(LiteralFact::Enum { span, value: value.to_string(), name: name.as_ref().map(|n| n.to_string()), - label, + label: ValueOption::label(self.labels, name.as_deref(), value), valid: values.iter().any(|v| v.as_ref() == value), enum_index: self.intern(name.as_ref(), &values), }) } } -fn bool_fact(span: Span, value: bool, expected: Option<&VariableType>) -> Option { - expected - .is_some_and(|e| matches!(e.unwrap_nullable().0, VariableType::Bool)) - .then_some(LiteralFact::Bool { span, value }) -} -fn is_today_call(node: &Node) -> bool { - matches!( - node, - Node::MethodCall { - kind: MethodKind::DateMethod(DateMethod::StartOf), - this: Node::FunctionCall { - kind: FunctionKind::Internal(InternalFunction::Date), - arguments: [], - }, - arguments: [Node::String("day")], - } - ) -} - -fn date_arg(arguments: &[&Node]) -> Option { - match arguments { - [] => Some(DateArg::Now), - [Node::String(value)] => Some(DateArg::Literal { - value: value.to_string(), - valid: date_valid(value), - tz: None, - }), - [Node::String(value), Node::String(tz)] => Some(DateArg::Literal { - value: value.to_string(), - valid: Tz::from_str(tz).ok().is_some_and(|zone| { - VmDate::new(Variable::String((*value).into()), Some(zone)).is_valid() - }), - tz: Some(tz.to_string()), - }), - [field @ (Node::Identifier(_) | Node::Member { .. })] => Some(DateArg::Field { - path: field_path(field)?.join("."), - }), - _ => None, - } -} enum TokenLiteral<'a> { Str { span: Span, @@ -430,7 +345,7 @@ enum TokenLiteral<'a> { }, } -impl TokenLiteral<'_> { +impl<'a> TokenLiteral<'a> { fn span(&self) -> Span { match self { TokenLiteral::Str { span, .. } @@ -438,205 +353,295 @@ impl TokenLiteral<'_> { | TokenLiteral::Date { span, .. } => *span, } } -} -fn token_literals<'a>(tokens: &[Token<'a>]) -> Vec> { - let mut out = Vec::new(); - let mut i = 0; - while i < tokens.len() { - let t = &tokens[i]; - match t.kind { + fn collect(tokens: &[Token<'a>]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let t = &tokens[i]; + match t.kind { + TokenKind::QuotationMark( + q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), + ) => { + let closed = + tokens + .get(i + 1) + .zip(tokens.get(i + 2)) + .filter(|(body, close)| { + body.kind == TokenKind::Literal + && close.kind == TokenKind::QuotationMark(q) + && close.span.0 < close.span.1 + }); + if let Some((body, close)) = closed { + out.push(Self::Str { + span: (t.span.0, close.span.1), + value: body.value, + }); + } + i += 3; + continue; + } + TokenKind::Boolean(value) => out.push(Self::Bool { + span: t.span, + value, + }), + TokenKind::Literal if t.value == "d" => { + let opens = tokens + .get(i + 1) + .is_some_and(|n| n.kind == TokenKind::Bracket(Bracket::LeftParenthesis)); + let method = i + .checked_sub(1) + .is_some_and(|k| tokens[k].kind == TokenKind::Operator(Operator::Dot)); + if let Some(last) = (opens && !method) + .then(|| Self::call_end(tokens, i + 1)) + .flatten() + .map(|last| { + let bare = last == i + 2; + bare.then(|| Self::today_end(tokens, last)) + .flatten() + .unwrap_or(last) + }) + { + out.push(Self::Date { + span: (t.span.0, tokens[last].span.1), + first: i, + last, + }); + } + } + _ => {} + } + i += 1; + } + out + } + + fn today_end(tokens: &[Token], close: usize) -> Option { + let rest = tokens.get(close + 1..close + 8)?; + let quote = match rest[3].kind { TokenKind::QuotationMark( q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), - ) => { - let closed = tokens - .get(i + 1) - .zip(tokens.get(i + 2)) - .filter(|(body, close)| { - body.kind == TokenKind::Literal - && close.kind == TokenKind::QuotationMark(q) - && close.span.0 < close.span.1 - }); - if let Some((body, close)) = closed { - out.push(TokenLiteral::Str { - span: (t.span.0, close.span.1), - value: body.value, - }); + ) => q, + _ => return None, + }; + let shape = rest[0].kind == TokenKind::Operator(Operator::Dot) + && rest[1].kind == TokenKind::Literal + && rest[1].value == "startOf" + && rest[2].kind == TokenKind::Bracket(Bracket::LeftParenthesis) + && rest[4].kind == TokenKind::Literal + && rest[4].value == "day" + && rest[5].kind == TokenKind::QuotationMark(quote) + && rest[5].span.0 < rest[5].span.1 + && rest[6].kind == TokenKind::Bracket(Bracket::RightParenthesis); + shape.then_some(close + 7) + } + + fn call_end(tokens: &[Token], open: usize) -> Option { + let mut depth = 0u32; + for (i, t) in tokens.iter().enumerate().skip(open) { + match t.kind { + TokenKind::Bracket( + Bracket::LeftParenthesis + | Bracket::LeftSquareBracket + | Bracket::LeftCurlyBracket, + ) => depth += 1, + TokenKind::Bracket(_) => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } } - i += 3; - continue; + _ => {} } - TokenKind::Boolean(value) => out.push(TokenLiteral::Bool { - span: t.span, - value, + } + None + } +} + +impl LiteralFact { + fn for_bool(span: Span, value: bool, expected: Option<&VariableType>) -> Option { + expected + .is_some_and(|e| matches!(e.unwrap_nullable().0, VariableType::Bool)) + .then_some(LiteralFact::Bool { span, value }) + } +} + +impl DateArg { + fn is_today(node: &Node) -> bool { + matches!( + node, + Node::MethodCall { + kind: MethodKind::DateMethod(DateMethod::StartOf), + this: Node::FunctionCall { + kind: FunctionKind::Internal(InternalFunction::Date), + arguments: [], + }, + arguments: [Node::String("day")], + } + ) + } + + fn from_arguments(arguments: &[&Node]) -> Option { + match arguments { + [] => Some(DateArg::Now), + [Node::String(value)] => Some(DateArg::Literal { + value: value.to_string(), + valid: Self::is_valid(value), + tz: None, }), - TokenKind::Literal if t.value == "d" => { - let opens = tokens - .get(i + 1) - .is_some_and(|n| n.kind == TokenKind::Bracket(Bracket::LeftParenthesis)); - let method = i - .checked_sub(1) - .is_some_and(|k| tokens[k].kind == TokenKind::Operator(Operator::Dot)); - if let Some(last) = (opens && !method) - .then(|| call_end(tokens, i + 1)) - .flatten() - .map(|last| { - let bare = last == i + 2; - bare.then(|| today_end(tokens, last)) - .flatten() - .unwrap_or(last) - }) - { - out.push(TokenLiteral::Date { - span: (t.span.0, tokens[last].span.1), - first: i, - last, - }); - } - } - _ => {} - } - i += 1; - } - out -} -fn today_end(tokens: &[Token], close: usize) -> Option { - let rest = tokens.get(close + 1..close + 8)?; - let quote = match rest[3].kind { - TokenKind::QuotationMark(q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote)) => { - q - } - _ => return None, - }; - let shape = rest[0].kind == TokenKind::Operator(Operator::Dot) - && rest[1].kind == TokenKind::Literal - && rest[1].value == "startOf" - && rest[2].kind == TokenKind::Bracket(Bracket::LeftParenthesis) - && rest[4].kind == TokenKind::Literal - && rest[4].value == "day" - && rest[5].kind == TokenKind::QuotationMark(quote) - && rest[5].span.0 < rest[5].span.1 - && rest[6].kind == TokenKind::Bracket(Bracket::RightParenthesis); - shape.then_some(close + 7) -} - -fn call_end(tokens: &[Token], open: usize) -> Option { - let mut depth = 0u32; - for (i, t) in tokens.iter().enumerate().skip(open) { - match t.kind { - TokenKind::Bracket( - Bracket::LeftParenthesis | Bracket::LeftSquareBracket | Bracket::LeftCurlyBracket, - ) => depth += 1, - TokenKind::Bracket(_) => { - depth = depth.checked_sub(1)?; - if depth == 0 { - return Some(i); - } - } - _ => {} - } - } - None -} -fn token_date_arg(parsed: &Parsed, first: usize, last: usize) -> Option { - let tokens = &parsed.tokens[first..=last]; - let result = Parser::try_new(tokens, parsed.arena) - .ok()? - .standard() - .parse(); - if !result.is_complete || result.root.has_error() { - return None; - } - match result.root { - node if is_today_call(node) => Some(DateArg::Today), - Node::FunctionCall { - kind: FunctionKind::Internal(InternalFunction::Date), - arguments, - } => date_arg(arguments), - _ => None, - } -} - -pub(crate) fn facts( - parsed: &Parsed, - table: &NodeTable, - unary: bool, - expected: Option<&VariableType>, - labels: Option<&LabelResolver>, -) -> (Vec, Vec) { - let mut interner = EnumInterner::new(labels); - let mut facts: Vec = Vec::new(); - let open_start = parsed.open_string.map(|(_, start)| start); - let is_open = |span: Span| open_start == Some(span.0) || span.1 > parsed.source.len() as u32; - - for entry in &table.entries { - let inside_today = facts.iter().any(|f| { - matches!(f, LiteralFact::Date { arg: DateArg::Today, span } - if span.0 <= entry.span.0 && entry.span.1 <= span.1) - }); - let fact = match entry.node { - Node::String(value) if !is_open(entry.span) && !inside_today => { - interner.enum_fact(entry.span, value, entry.expected.as_ref()) - } - Node::Bool(value) => bool_fact(entry.span, *value, entry.expected.as_ref()), - node if is_today_call(node) => Some(LiteralFact::Date { - span: entry.span, - arg: DateArg::Today, + [Node::String(value), Node::String(tz)] => Some(DateArg::Literal { + value: value.to_string(), + valid: Tz::from_str(tz).ok().is_some_and(|zone| { + VmDate::new(Variable::String((*value).into()), Some(zone)).is_valid() + }), + tz: Some(tz.to_string()), }), + [field @ (Node::Identifier(_) | Node::Member { .. })] => Some(DateArg::Field { + path: Self::field_path(field)?.join("."), + }), + _ => None, + } + } + + fn from_tokens(parsed: &Parsed, first: usize, last: usize) -> Option { + match parsed.reparse(first, last)? { + node if Self::is_today(node) => Some(DateArg::Today), Node::FunctionCall { kind: FunctionKind::Internal(InternalFunction::Date), arguments, - } if !inside_today => { - let open_arg = arguments - .first() - .is_some_and(|a| matches!(a, Node::String(_)) && is_open(parsed.span_of(a))); - if open_arg { - None - } else { - date_arg(arguments).map(|arg| LiteralFact::Date { - span: entry.span, - arg, - }) + } => Self::from_arguments(arguments), + _ => None, + } + } + + fn is_valid(value: &str) -> bool { + VmDate::new(Variable::String(value.into()), None).is_valid() + } + + fn field_path(node: &Node) -> Option> { + match node { + Node::Identifier(name) => Some(vec![name.to_string()]), + Node::Pointer => Some(vec!["#".to_string()]), + Node::Root => Some(vec!["$root".to_string()]), + Node::Member { node, property } => { + let mut base = Self::field_path(node)?; + match property { + Node::String(s) => base.push(s.to_string()), + Node::Root => base.push("$root".to_string()), + Node::Number(n) if n.is_integer() && !n.is_sign_negative() => { + let last = base.pop()?; + base.push(format!("{last}[{n}]")); + } + _ => return None, } + Some(base) } _ => None, - }; - facts.extend(fact); - } - - // A clean parse is authoritative; only error recovery loses literals, recovered from tokens. - if parsed.complete && !parsed.ast.has_error() { - facts.sort_by_key(|f| f.span()); - return (facts, interner.tables); - } - let operands = Operands::new(parsed, table, unary, expected, labels); - for literal in token_literals(parsed.tokens) { - let span = literal.span(); - let covered = facts.iter().any(|f| { - let s = f.span(); - s.0 <= span.0 && span.1 <= s.1 - }); - if covered { - continue; } - let fact = match literal { - TokenLiteral::Str { span, value } => { - interner.enum_fact(span, value, operands.expected_at(span.0).as_ref()) - } - TokenLiteral::Bool { span, value } => { - bool_fact(span, value, operands.expected_at(span.0).as_ref()) - } - TokenLiteral::Date { span, first, last } => { - token_date_arg(parsed, first, last).map(|arg| LiteralFact::Date { span, arg }) - } - }; - facts.extend(fact); + } +} + +impl Literals { + pub fn map_spans(mut self, f: impl Fn(Span) -> Span) -> Self { + self.facts = self + .facts + .into_iter() + .map(|fact| fact.map_span(&f)) + .collect(); + self } - facts.sort_by_key(|f| f.span()); - (facts, interner.tables) -} + pub(crate) fn empty(source: &str) -> Self { + Self { + complete: source.trim().is_empty(), + facts: Vec::new(), + enums: Vec::new(), + } + } -pub(crate) fn date_valid(value: &str) -> bool { - VmDate::new(Variable::String(value.into()), None).is_valid() + pub(crate) fn collect( + parsed: &Parsed, + table: &NodeTable, + unary: bool, + expected: Option<&VariableType>, + labels: Option<&LabelResolver>, + ) -> Self { + let mut interner = EnumInterner::new(labels); + let mut facts: Vec = Vec::new(); + let open_start = parsed.open_string.map(|(_, start)| start); + let is_open = + |span: Span| open_start == Some(span.0) || span.1 > parsed.source.len() as u32; + + for entry in &table.entries { + let inside_today = facts.iter().any(|f| { + matches!(f, LiteralFact::Date { arg: DateArg::Today, span } + if span.0 <= entry.span.0 && entry.span.1 <= span.1) + }); + let fact = match entry.node { + Node::String(value) if !is_open(entry.span) && !inside_today => { + interner.enum_fact(entry.span, value, entry.expected.as_ref()) + } + Node::Bool(value) => { + LiteralFact::for_bool(entry.span, *value, entry.expected.as_ref()) + } + node if DateArg::is_today(node) => Some(LiteralFact::Date { + span: entry.span, + arg: DateArg::Today, + }), + Node::FunctionCall { + kind: FunctionKind::Internal(InternalFunction::Date), + arguments, + } if !inside_today => { + let open_arg = arguments.first().is_some_and(|a| { + matches!(a, Node::String(_)) && is_open(parsed.span_of(a)) + }); + if open_arg { + None + } else { + DateArg::from_arguments(arguments).map(|arg| LiteralFact::Date { + span: entry.span, + arg, + }) + } + } + _ => None, + }; + facts.extend(fact); + } + + if !parsed.complete || parsed.ast.has_error() { + let operands = + Classifier::new(parsed, table, unary, SlotRole::Condition, expected, labels); + for literal in TokenLiteral::collect(parsed.tokens) { + let span = literal.span(); + let covered = facts.iter().any(|f| { + let s = f.span(); + s.0 <= span.0 && span.1 <= s.1 + }); + if covered { + continue; + } + let fact = match literal { + TokenLiteral::Str { span, value } => { + interner.enum_fact(span, value, operands.expected_at(span.0).as_ref()) + } + TokenLiteral::Bool { span, value } => { + LiteralFact::for_bool(span, value, operands.expected_at(span.0).as_ref()) + } + TokenLiteral::Date { span, first, last } => { + DateArg::from_tokens(parsed, first, last) + .map(|arg| LiteralFact::Date { span, arg }) + } + }; + facts.extend(fact); + } + } + + facts.sort_by_key(|f| f.span()); + Self { + complete: parsed.is_complete(), + facts, + enums: interner.tables, + } + } } diff --git a/core/expression/src/slot/mod.rs b/core/expression/src/slot/mod.rs index 02c64f24..c9e54cb5 100644 --- a/core/expression/src/slot/mod.rs +++ b/core/expression/src/slot/mod.rs @@ -13,9 +13,8 @@ use crate::variable::VariableType; mod classify; mod literals; mod operators; -#[cfg(test)] -mod tests; +use classify::Classifier; pub(crate) use literals::NodeTable; pub type LabelResolver = Rc Option>; @@ -85,7 +84,6 @@ pub struct Slot { } impl Slot { - /// Expected scalar type used to filter field completions. pub fn wanted_scalar(&self) -> Option<&VariableType> { if !matches!( self.state, @@ -99,7 +97,7 @@ impl Slot { return None; } let (t, _) = self.expected.as_ref()?.unwrap_nullable(); - scalar_class(t).map(|_| t) + ScalarClass::of(t).map(|_| t) } pub(crate) fn new(state: SlotState, replace_span: Span) -> Self { @@ -155,6 +153,15 @@ impl LiteralFact { | LiteralFact::Bool { span, .. } => *span, } } + + pub fn map_span(mut self, f: impl Fn(Span) -> Span) -> Self { + match &mut self { + LiteralFact::Enum { span, .. } + | LiteralFact::Date { span, .. } + | LiteralFact::Bool { span, .. } => *span = f(*span), + } + self + } } #[derive(Debug, Clone, Serialize, PartialEq)] @@ -186,83 +193,102 @@ pub struct EnumTable { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct SlotResult { - pub unary: bool, +pub struct Literals { pub complete: bool, - pub slot: Slot, - pub literals: Vec, + #[serde(rename = "literals")] + pub facts: Vec, pub enums: Vec, } -pub fn encode_string(value: &str) -> Option { - if !value.contains('"') { - Some(format!("\"{value}\"")) - } else if !value.contains('\'') { - Some(format!("'{value}'")) - } else { - None - } -} - -pub fn is_date_type(kind: &VariableType) -> bool { - match kind { - VariableType::Date => true, - VariableType::Array(inner) | VariableType::Nullable(inner) => is_date_type(inner), - _ => false, - } -} - -pub(crate) fn enum_options( - name: Option<&str>, - values: &[Rc], - labels: Option<&LabelResolver>, -) -> Vec { - values - .iter() - .map(|v| ValueOption { - value: v.to_string(), - label: labels - .zip(name) - .and_then(|(resolve, n)| resolve(n, v)) - .filter(|l| !l.is_empty()) - .unwrap_or_else(|| v.to_string()), - source: encode_string(v), - }) - .collect() -} - -pub fn subject_enum_options( - t: &VariableType, - labels: Option<&LabelResolver>, -) -> Option> { - let (name, values) = enum_values(t)?; - Some(enum_options(name.as_deref(), &values, labels)) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SlotResult { + pub slot: Slot, + pub literals: Literals, } pub(crate) type EnumDomain = (Option>, Vec>); -pub(crate) const NULL_SOURCE: &str = "null"; +pub trait VariableTypeSlot { + fn innermost(&self) -> &VariableType; + fn enum_domain(&self) -> Option; + fn declared(self) -> Option; +} -pub(crate) fn null_option() -> ValueOption { - ValueOption { - value: NULL_SOURCE.to_string(), - label: NULL_SOURCE.to_string(), - source: Some(NULL_SOURCE.to_string()), +impl VariableTypeSlot for VariableType { + fn innermost(&self) -> &VariableType { + match self { + VariableType::Array(inner) | VariableType::Nullable(inner) => inner.innermost(), + other => other, + } + } + + fn enum_domain(&self) -> Option { + match self.innermost() { + VariableType::Enum(name, values) => Some((name.clone(), values.clone())), + VariableType::Const(value) => Some((None, vec![value.clone()])), + _ => None, + } + } + + fn declared(self) -> Option { + match self { + VariableType::Any => None, + other => Some(other), + } } } -pub(crate) fn is_null_option(option: &ValueOption) -> bool { - option.value == NULL_SOURCE && option.source.as_deref() == Some(NULL_SOURCE) -} +impl ValueOption { + pub(crate) const NULL: &'static str = "null"; -pub(crate) fn enum_values(t: &VariableType) -> Option { - let mut current = t; - loop { - match current { - VariableType::Enum(name, values) => return Some((name.clone(), values.clone())), - VariableType::Const(value) => return Some((None, vec![value.clone()])), - VariableType::Nullable(inner) | VariableType::Array(inner) => current = inner, - _ => return None, + pub fn for_type(t: &VariableType, labels: Option<&LabelResolver>) -> Option> { + let (name, values) = t.enum_domain()?; + Some(Self::enum_list(name.as_deref(), &values, labels)) + } + + pub(crate) fn enum_list( + name: Option<&str>, + values: &[Rc], + labels: Option<&LabelResolver>, + ) -> Vec { + values + .iter() + .map(|v| Self { + value: v.to_string(), + label: Self::label(labels, name, v), + source: Self::encode(v), + }) + .collect() + } + + pub(crate) fn label(labels: Option<&LabelResolver>, name: Option<&str>, value: &str) -> String { + labels + .zip(name) + .and_then(|(resolve, n)| resolve(n, value)) + .filter(|l| !l.is_empty()) + .unwrap_or_else(|| value.to_string()) + } + + pub(crate) fn null() -> Self { + Self { + value: Self::NULL.to_string(), + label: Self::NULL.to_string(), + source: Some(Self::NULL.to_string()), + } + } + + pub(crate) fn is_null(&self) -> bool { + self.value == Self::NULL && self.source.as_deref() == Some(Self::NULL) + } + + fn encode(value: &str) -> Option { + if !value.contains('"') { + Some(format!("\"{value}\"")) + } else if !value.contains('\'') { + Some(format!("'{value}'")) + } else { + None } } } @@ -295,7 +321,6 @@ impl<'a> Parsed<'a> { .unwrap_or(VariableType::Any) } - /// A run re-parsed on its own sees `#` as an identifier, so the pointer type is aliased too. pub(crate) fn intellisense_scope(&self, pointer: Option) -> IntelliSenseScope { let mut scope = IntelliSenseScope { root_data: self.scope.shallow_clone(), @@ -313,49 +338,66 @@ impl<'a> Parsed<'a> { } } -fn parse_partial<'a>( - arena: &'a Bump, - lexer: &mut Lexer, - strict: bool, - source: &'a str, - unary: bool, - scope: &VariableType, -) -> Option> { - let lenient = lexer.tokenize_lenient(arena, source).ok()?; - let tokens: &'a [Token<'a>] = lenient.tokens.into_bump_slice(); - let parser = Parser::try_new(tokens, arena).ok()?; - let result = if unary { - parser.unary().with_metadata().parse() - } else { - parser.standard().with_metadata().parse() - }; - let ast = result.root; - let complete = result.is_complete; - let metadata = result.metadata.unwrap_or_default(); - let is_scope = IntelliSenseScope { - pointer_data: scope.shallow_clone(), - root_data: scope.shallow_clone(), - current_data: scope.shallow_clone(), - ..Default::default() - }; - let types = TypesProvider::generate(ast, is_scope, strict); +impl<'a> Parsed<'a> { + fn new( + arena: &'a Bump, + lexer: &mut Lexer, + strict: bool, + source: &'a str, + unary: bool, + scope: &VariableType, + ) -> Option { + let lenient = lexer.tokenize_lenient(arena, source).ok()?; + let tokens: &'a [Token<'a>] = lenient.tokens.into_bump_slice(); + let parser = Parser::try_new(tokens, arena).ok()?; + let result = if unary { + parser.unary().with_metadata().parse() + } else { + parser.standard().with_metadata().parse() + }; + let is_scope = IntelliSenseScope { + pointer_data: scope.shallow_clone(), + root_data: scope.shallow_clone(), + current_data: scope.shallow_clone(), + ..Default::default() + }; - Some(Parsed { - arena, - source, - tokens, - open_string: lenient.open_string, - ast, - complete, - metadata, - types, - scope: scope.shallow_clone(), - strict, - }) + Some(Self { + arena, + source, + tokens, + open_string: lenient.open_string, + ast: result.root, + complete: result.is_complete, + metadata: result.metadata.unwrap_or_default(), + types: TypesProvider::generate(result.root, is_scope, strict), + scope: scope.shallow_clone(), + strict, + }) + } + + fn clamp(source: &str, pos: u32) -> u32 { + let mut pos = (pos as usize).min(source.len()); + while pos > 0 && !source.is_char_boundary(pos) { + pos -= 1; + } + pos as u32 + } + + pub(crate) fn reparse(&self, first: usize, last: usize) -> Option<&'a Node<'a>> { + let result = Parser::try_new(&self.tokens[first..=last], self.arena) + .ok()? + .standard() + .parse(); + (result.is_complete && !result.root.has_error()).then_some(result.root) + } + + fn is_complete(&self) -> bool { + self.complete && !self.ast.has_error() && self.open_string.is_none() + } } impl IntelliSense { - /// Slot at byte `pos` plus literal facts for the whole `source`. `scope` already holds `$` for unary. pub fn slot( &mut self, source: &str, @@ -366,8 +408,9 @@ impl IntelliSense { expected: Option<&VariableType>, ) -> SlotResult { self.arena.reset(); - let pos = clamp_pos(source, pos); - let Some(parsed) = parse_partial( + let pos = Parsed::clamp(source, pos); + let labels = self.labels.as_ref(); + let Some(parsed) = Parsed::new( &self.arena, &mut self.lexer, self.strict, @@ -376,45 +419,17 @@ impl IntelliSense { scope, ) else { return SlotResult { - unary, - complete: source.trim().is_empty(), - slot: classify::fallback( - source, - pos, - unary, - role, - scope, - expected, - self.labels.as_ref(), - ), - literals: Vec::new(), - enums: Vec::new(), + slot: Classifier::fallback(source, pos, unary, role, scope, expected, labels), + literals: Literals::empty(source), }; }; - let table = NodeTable::build(&parsed, unary, expected); - let (literals, enums) = - literals::facts(&parsed, &table, unary, expected, self.labels.as_ref()); - let slot = classify::classify( - &parsed, - &table, - pos, - unary, - role, - expected, - self.labels.as_ref(), - ); - SlotResult { - unary, - complete: parsed.complete && !parsed.ast.has_error() && parsed.open_string.is_none(), - slot, - literals, - enums, + slot: Classifier::classify(&parsed, &table, pos, unary, role, expected, labels), + literals: Literals::collect(&parsed, &table, unary, expected, labels), } } - /// Closure bindings visible at byte `pos`, innermost first. pub fn closure_locals( &mut self, source: &str, @@ -422,8 +437,8 @@ impl IntelliSense { scope: &VariableType, ) -> Vec<(Rc, VariableType)> { self.arena.reset(); - let pos = clamp_pos(source, pos); - let Some(parsed) = parse_partial( + let pos = Parsed::clamp(source, pos); + let Some(parsed) = Parsed::new( &self.arena, &mut self.lexer, self.strict, @@ -434,7 +449,7 @@ impl IntelliSense { return Vec::new(); }; let table = NodeTable::build(&parsed, false, None); - classify::closure_locals(&parsed, &table, pos) + Classifier::closure_locals(&parsed, &table, pos) } pub fn literals( @@ -443,20 +458,10 @@ impl IntelliSense { unary: bool, scope: &VariableType, expected: Option<&VariableType>, - ) -> (Vec, Vec) { - let (literals, enums, _) = self.literal_analysis(source, unary, scope, expected); - (literals, enums) - } - - pub fn literal_analysis( - &mut self, - source: &str, - unary: bool, - scope: &VariableType, - expected: Option<&VariableType>, - ) -> (Vec, Vec, bool) { + ) -> Literals { self.arena.reset(); - let Some(parsed) = parse_partial( + let labels = self.labels.as_ref(); + let Some(parsed) = Parsed::new( &self.arena, &mut self.lexer, self.strict, @@ -464,53 +469,39 @@ impl IntelliSense { unary, scope, ) else { - return (Vec::new(), Vec::new(), source.trim().is_empty()); + return Literals::empty(source); }; - let table = NodeTable::build(&parsed, unary, expected); - let (literals, enums) = - literals::facts(&parsed, &table, unary, expected, self.labels.as_ref()); - ( - literals, - enums, - parsed.complete && !parsed.ast.has_error() && parsed.open_string.is_none(), - ) + Literals::collect(&parsed, &table, unary, expected, labels) } } -fn clamp_pos(source: &str, pos: u32) -> u32 { - let mut pos = (pos as usize).min(source.len()); - while pos > 0 && !source.is_char_boundary(pos) { - pos -= 1; - } - pos as u32 -} - #[derive(PartialEq, Eq, Clone, Copy)] -enum ScalarClass { +pub(crate) 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) +impl ScalarClass { + fn of(t: &VariableType) -> Option { + match t { + VariableType::Bool => Some(Self::Bool), + VariableType::Number => Some(Self::Number), + VariableType::String | VariableType::Const(_) | VariableType::Enum(..) => { + Some(Self::String) + } + VariableType::Date => Some(Self::Date), + _ => None, } - VariableType::Date => Some(ScalarClass::Date), - _ => None, } -} -/// Scalars must match; containers may provide a matching nested field. -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), + pub(crate) fn fits(field: &VariableType, wanted: &VariableType) -> bool { + let (field, _) = field.unwrap_nullable(); + match Self::of(field) { + Some(class) => Self::of(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 fccdfbbe..5d0bdbfb 100644 --- a/core/expression/src/slot/operators.rs +++ b/core/expression/src/slot/operators.rs @@ -1,65 +1,62 @@ use crate::variable::VariableType; -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"]; -pub(crate) const EQUALITY: &[&str] = &["==", "!="]; -const MEMBERSHIP: &[&str] = &["in", "not in"]; -pub(crate) fn nullable_extras(unary: bool) -> &'static [&'static str] { - if unary { - &["==", "!="] - } else { - &["==", "!=", "??"] - } -} -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) { - continue; - } - if *op == "??" { - list.push(op); - } else { - list.insert(front, op); - front += 1; - } - } - return list; - } - let list: &[&str] = match t { - VariableType::Number | VariableType::Date => { - if unary { - ORDERED_UNARY - } else { - ORDERED - } - } - VariableType::Enum(_, _) | VariableType::Const(_) | VariableType::String => { - if unary { - TEXT_UNARY - } else { - TEXT - } - } - VariableType::Bool => EQUALITY, - VariableType::Array(_) => MEMBERSHIP, - VariableType::Any => { - if unary { - ORDERED_UNARY - } else { - ORDERED - } - } - VariableType::Null - | VariableType::Object(_) - | VariableType::Interval - | VariableType::Nullable(_) => EQUALITY, - }; - list.to_vec() -} +pub(crate) struct Operators; -pub(crate) const LOGICAL: &[&str] = &["and", "or"]; +impl Operators { + 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 MEMBERSHIP: &[&str] = &["in", "not in"]; + pub(crate) const EQUALITY: &[&str] = &["==", "!="]; + pub(crate) const LOGICAL: &[&str] = &["and", "or"]; + + pub(crate) fn nullable_extras(unary: bool) -> &'static [&'static str] { + if unary { + Self::EQUALITY + } else { + &["==", "!=", "??"] + } + } + + pub(crate) fn for_type(t: &VariableType, unary: bool) -> Vec<&'static str> { + if let VariableType::Nullable(inner) = t { + let mut list = Self::for_type(inner, unary); + let mut front = 0; + for op in Self::nullable_extras(unary) { + if list.contains(op) { + continue; + } + if *op == "??" { + list.push(op); + } else { + list.insert(front, op); + front += 1; + } + } + return list; + } + let list: &[&str] = match (t, unary) { + (VariableType::Number | VariableType::Date | VariableType::Any, true) => { + Self::ORDERED_UNARY + } + (VariableType::Number | VariableType::Date | VariableType::Any, false) => Self::ORDERED, + (VariableType::Enum(..) | VariableType::Const(_) | VariableType::String, true) => { + Self::TEXT_UNARY + } + (VariableType::Enum(..) | VariableType::Const(_) | VariableType::String, false) => { + Self::TEXT + } + (VariableType::Array(_), _) => Self::MEMBERSHIP, + ( + VariableType::Bool + | VariableType::Null + | VariableType::Object(_) + | VariableType::Interval + | VariableType::Nullable(_), + _, + ) => Self::EQUALITY, + }; + list.to_vec() + } +} diff --git a/core/expression/src/slot/tests.rs b/core/expression/src/slot/tests.rs deleted file mode 100644 index a0277796..00000000 --- a/core/expression/src/slot/tests.rs +++ /dev/null @@ -1,8 +0,0 @@ -use super::encode_string; - -#[test] -fn encode_string_picks_a_quote() { - assert_eq!(encode_string("open").as_deref(), Some("\"open\"")); - assert_eq!(encode_string("say \"hi\"").as_deref(), Some("'say \"hi\"'")); - assert_eq!(encode_string("it's \"x\""), None); -} diff --git a/core/expression/src/vm/date/mod.rs b/core/expression/src/vm/date/mod.rs index ebbc8df8..30246b96 100644 --- a/core/expression/src/vm/date/mod.rs +++ b/core/expression/src/vm/date/mod.rs @@ -64,12 +64,7 @@ impl VmDate { /// Create a new VmDate from the current time pub fn new(var: Variable, tz_opt: Option) -> Self { - Self(helper::parse_date(var, tz_opt, true)) - } - - /// Parse a value without treating a time-zone name as the current time. - pub fn from_literal(var: Variable, tz_opt: Option) -> Self { - Self(helper::parse_date(var, tz_opt, false)) + Self(helper::parse_date(var, tz_opt)) } pub fn is_valid(&self) -> bool { @@ -212,7 +207,7 @@ mod helper { utc_now().with_timezone(&tz) } - pub fn parse_date(var: Variable, tz_opt: Option, allow_zone: bool) -> Option> { + pub fn parse_date(var: Variable, tz_opt: Option) -> Option> { let tz = tz_opt.unwrap_or_else(|| tz()); match var { @@ -242,7 +237,7 @@ mod helper { }) .map(|dt| tz.from_local_datetime(&dt).earliest()) }) - .or_else(|| allow_zone.then(|| Tz::from_str(&str.deref()).ok().map(now_tz))) + .or_else(|| Some(Tz::from_str(&str.deref()).ok().map(now_tz))) .flatten(), Variable::Dynamic(d) => match d.as_date() { Some(d) => d.0.clone(), diff --git a/core/expression/src/vm/vm.rs b/core/expression/src/vm/vm.rs index 8d870132..6a0f2e15 100644 --- a/core/expression/src/vm/vm.rs +++ b/core/expression/src/vm/vm.rs @@ -9,7 +9,6 @@ use crate::vm::date::DynamicVariableExt; use crate::vm::error::VMError::*; use crate::vm::error::VMResult; use crate::vm::interval::{VmInterval, VmIntervalData}; -use crate::vm::VmDate; use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; use rust_decimal::{Decimal, MathematicalOps}; use std::rc::Rc; @@ -251,10 +250,6 @@ impl<'arena, 'parent_ref, 'bytecode_ref> VMInner<'parent_ref, 'bytecode_ref> { self.push(Bool(a.is_some() && b.is_some() && a == b)); } - (String(a), Dynamic(b)) | (Dynamic(b), String(a)) => { - let parsed = VmDate::from_literal(String(a), None); - self.push(Bool(parsed.is_valid() && b.as_date() == Some(&parsed))); - } _ => { self.push(Bool(false)); } @@ -388,10 +383,6 @@ impl<'arena, 'parent_ref, 'bytecode_ref> VMInner<'parent_ref, 'bytecode_ref> { let arr = arr.borrow(); let is_in = arr.iter().any(|b| match b { Dynamic(b) => Some(a) == b.as_date(), - String(b) => { - let parsed = VmDate::from_literal(String(b.clone()), None); - parsed.is_valid() && *a == parsed - } _ => false, }); @@ -399,38 +390,13 @@ impl<'arena, 'parent_ref, 'bytecode_ref> VMInner<'parent_ref, 'bytecode_ref> { } (String(a), Array(b)) => { let arr = b.borrow(); - let parsed = arr - .iter() - .any(|b| matches!(b, Dynamic(_))) - .then(|| VmDate::from_literal(String(a.clone()), None)) - .filter(|d| d.is_valid()); let is_in = arr.iter().any(|b| match b { String(b) => &a == b, - Dynamic(b) => { - parsed.as_ref().is_some_and(|p| Some(p) == b.as_date()) - } _ => false, }); self.push(Bool(is_in)); } - (String(a), Dynamic(i)) => { - let parsed = VmDate::from_literal(String(a), None); - let interval = i.as_any().downcast_ref::(); - let Some(i) = interval.filter(|_| parsed.is_valid()) else { - return Err(OpcodeErr { - opcode: "In".into(), - message: "Unsupported type".into(), - }); - }; - - self.push(Bool(i.includes(VmIntervalData::Date(parsed)).map_err( - |err| OpcodeErr { - opcode: "In".into(), - message: err.to_string(), - }, - )?)); - } (String(a), Object(b)) => { let obj = b.borrow(); self.push(Bool(obj.contains_key_str(a.as_str()))); @@ -483,34 +449,12 @@ impl<'arena, 'parent_ref, 'bytecode_ref> VMInner<'parent_ref, 'bytecode_ref> { return Err(OpcodeErr { opcode: "Compare".into(), message: "Unsupported type".into(), - }); + }) } }; self.push(Bool(compare(a, b, comparison))); } - (String(a), Dynamic(b)) => { - let parsed = VmDate::from_literal(String(a), None); - let Some(b) = b.as_date().filter(|_| parsed.is_valid()) else { - return Err(OpcodeErr { - opcode: "Compare".into(), - message: "Unsupported type".into(), - }); - }; - - self.push(Bool(compare(&parsed, b, comparison))); - } - (Dynamic(a), String(b)) => { - let parsed = VmDate::from_literal(String(b), None); - let Some(a) = a.as_date().filter(|_| parsed.is_valid()) else { - return Err(OpcodeErr { - opcode: "Compare".into(), - message: "Unsupported type".into(), - }); - }; - - self.push(Bool(compare(a, &parsed, comparison))); - } _ => { return Err(OpcodeErr { opcode: "Compare".into(), @@ -674,7 +618,7 @@ impl<'arena, 'parent_ref, 'bytecode_ref> VMInner<'parent_ref, 'bytecode_ref> { return Err(OpcodeErr { opcode: "Interval".into(), message: "Unsupported type".into(), - }); + }) } }; diff --git a/core/expression/tests/analysis.rs b/core/expression/tests/analysis.rs index 0e48bf4e..e458dab8 100644 --- a/core/expression/tests/analysis.rs +++ b/core/expression/tests/analysis.rs @@ -648,100 +648,3 @@ 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/date.csv b/core/expression/tests/data/date.csv index c23a6625..d61b052b 100644 --- a/core/expression/tests/data/date.csv +++ b/core/expression/tests/data/date.csv @@ -244,24 +244,4 @@ max([d('2023-01-31'), d('2023-02-28'), d('2023-03-31')]);;'2023-03-31T00:00:00Z' min([d('2023-10-15').add(1, 'd'), d('2023-10-15').sub(1, 'd'), d('2023-10-15')]);;'2023-10-14T00:00:00Z' max([d('2023-10-15').add(1, 'd'), d('2023-10-15').sub(1, 'd'), d('2023-10-15')]);;'2023-10-16T00:00:00Z' min([d('2023-01-01'), d('2023-12-31')]).isBefore(d('2023-06-01'));;true -max([d('2023-01-01'), d('2023-12-31')]).isAfter(d('2023-06-01'));;true - -# Date-typed inputs arrive as ISO strings and compare against dates -since > d('2024-01-01');{since: '2024-01-15'};true -since <= d('2024-01-01');{since: '2024-01-15'};false -d('2024-01-01') < since;{since: '2024-01-15'};true -since == d('2024-01-15');{since: '2024-01-15'};true -since == d('2024-01-16');{since: '2024-01-15'};false - -# Date-typed inputs arrive as ISO strings and take part in membership and intervals -since in [d('2024-01-01')..d('2024-12-31')];{since: '2024-06-01'};true -since in [d('2024-01-01')..d('2024-12-31')];{since: '2025-06-01'};false -since in (d('2024-01-01')..d('2024-12-31'));{since: '2024-01-01'};false -since not in [d('2024-01-01')..d('2024-12-31')];{since: '2023-12-31'};true -since in [d('2024-01-01'), d('2024-02-01')];{since: '2024-02-01'};true -since in [d('2024-01-01'), d('2024-02-01')];{since: '2024-03-01'};false -since not in [d('2024-01-01'), d('2024-02-01')];{since: '2024-03-01'};true -since in [d('2024-01-01'), '2024-02-01'];{since: '2024-02-01'};true -d('2024-02-01') in [d('2024-01-01'), '2024-02-01'];;true -d('2024-02-01') in ['2024-01-01', 'nope'];;false -d('2024-02-01') in [since];{since: '2024-02-01'};true +max([d('2023-01-01'), d('2023-12-31')]).isAfter(d('2023-06-01'));;true \ No newline at end of file diff --git a/core/expression/tests/data/slots.csv b/core/expression/tests/data/slots.csv deleted file mode 100644 index 7a1fcacb..00000000 --- a/core/expression/tests/data/slots.csv +++ /dev/null @@ -1,702 +0,0 @@ -role;unary;scope;expected;text;state;expected_tag;options;operators;replace_span;auto_open - -# Standard start -condition;false;;bool;|;start;bool;;;0..0;true -condition;false;;bool;ag|;start;bool;;;0..2;false -value;false;;status;|;value;status;open=Open case,closed=Closed;;0..0;true -value;false;;number;|;value;number;;;0..0;true -value;false;;;|;start;;;;0..0;true -value;false;;bool;|;value;bool;;;0..0;true -# Value after comparison operators -condition;false;;bool;age == |;value;number;;;7..7;true -condition;false;;bool;age != |;value;number;;;7..7;true -condition;false;;bool;age > |;value;number;;;6..6;true -condition;false;;bool;age >= |;value;number;;;7..7;true -condition;false;;bool;age < |;value;number;;;6..6;true -condition;false;;bool;age <= |;value;number;;;7..7;true -condition;false;;bool;since < |;value;date;;;8..8;true -condition;false;;bool;status == |;value;status;open=Open case,closed=Closed;;10..10;true -condition;false;;bool;status != |;value;status;open=Open case,closed=Closed;;10..10;true -condition;false;;bool;tier == |;value;"gold" | "silver";gold,silver;;8..8;true -condition;false;;bool;active == |;value;bool;;;10..10;true -condition;false;;bool;status in |;value;status[];open=Open case,closed=Closed;;10..10;true -condition;false;;bool;status not in |;value;status[];open=Open case,closed=Closed;;14..14;true -condition;false;;bool;age == 1|;value;number;;;7..8;true -condition;false;;bool;age > -|;value;number;;;7..7;true -condition;false;;bool;age > -5|;value;number;;;7..8;true -condition;false;;bool;status == "open" and status == |;value;status;open=Open case,closed=Closed;;31..31;true -condition;false;;bool;(status == "open" or status == |;value;status;open=Open case,closed=Closed;;31..31;true -condition;false;;bool;(age > 1 and (status == |;value;status;open=Open case,closed=Closed;;24..24;true -# Inside string literals -condition;false;;bool;status == "|;inString;status;open=Open case,closed=Closed;;10..11;true -condition;false;;bool;status == "op|;inString;status;open=Open case;;10..13;true -condition;false;;bool;status == "clo|sed";inString;status;closed=Closed;;10..18;true -condition;false;;bool;status in ['op|;inString;status;open=Open case;;11..14;true -condition;false;;bool;startsWith(name, "|;inString;string;;;17..18;false -condition;false;;bool;d("|;inString;any;;;2..3;true -condition;false;;bool;name == "Ünïcode" and status == "|;inString;status;open=Open case,closed=Closed;;34..35;true -condition;false;;bool;mood == "|;inString;mood;happy=😀 Happy,sad=😢 Sad;;8..9;true -condition;false;;bool;mood == "😀|;inString;mood;happy=😀 Happy;;8..13;true -value;false;;string;`Hel|lo`;inString;string;;;0..7;true -value;false;;string;`Hello ${name}|;inString;string;;;0..14;true -# List elements and ranges -condition;false;;bool;status in [|;listElement;status;open=Open case,closed=Closed;;11..11;true -condition;false;;bool;status in ["open", |;listElement;status;closed=Closed;;19..19;true -condition;false;;bool;status in ["open", "closed", |;listElement;status;;;29..29;true -condition;false;;bool;age in [|;listElement;number;;;8..8;true -condition;false;;bool;age in [1..|;range;number;;;11..11;false -condition;false;;bool;age in (1..|;range;number;;;11..11;false -condition;false;;bool;age in [|..5];range;number;;;8..8;false -condition;false;;bool;since in [d()..|;range;date;;;15..15;false -condition;false;;bool;since in [|;listElement;date;;;10..10;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};[|;listElement;status;open=Open case,closed=Closed;;1..1;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};["closed", |;listElement;status;open=Open case;;11..11;true -condition;false;;bool;items[|;start;number;;;6..6;true -# Operator and logical after a finished operand -condition;false;;bool;age |;operator;;;==,!=,<,<=,>,>=,in,not in;4..4;true -# Operator menu opens after a complete operand (field path, literal, closed call) -condition;false;;bool;grounding.status |;operator;;;==,!=,in,not in;17..17;true -value;false;;status;grounding.status |;operator;;;==,!=,in,not in;17..17;true -condition;false;;bool;customer.age |;operator;;;==,!=,<,<=,>,>=,in,not in;13..13;true -condition;false;;bool;len(x) |;operator;;;==,!=,<,<=,>,>=,in,not in;7..7;true -condition;false;;bool;name |;operator;;;==,!=,in,not in;5..5;true -condition;false;;bool;status |;operator;;;==,!=,in,not in;7..7;true -condition;false;;bool;tags |;operator;;;in,not in;5..5;true -condition;false;;bool;customer.name |;operator;;;==,!=,in,not in;14..14;true -condition;false;;bool;amount |;operator;;;==,!=,<,<=,>,>=,in,not in,??;7..7;true -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;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 -condition;false;;bool;age > 1|;value;number;;;6..7;true -condition;false;;bool;age > 1 + 2 |;logical;bool;;and,or;12..12;true -condition;false;;bool;status == "closed"|;logical;bool;;and,or;18..18;false -condition;false;;bool;status == "closed" |;logical;bool;;and,or;19..19;true -condition;false;;bool;status in ["open"] |;logical;bool;;and,or;19..19;true -condition;false;;bool;age in [1..5] |;logical;bool;;and,or;14..14;true -condition;false;;bool;since > d() |;logical;bool;;and,or;12..12;true -condition;false;;bool;since > d().startOf("day") |;logical;bool;;and,or;27..27;true -condition;false;;bool;contains(name, "a") |;logical;bool;;and,or;20..20;true -condition;false;;bool;age > 1 an|;logical;bool;;and,or;8..10;true -condition;false;;bool;age > 1 and|;logical;bool;;and,or;8..11;true -condition;false;;bool;age > 1 and age |;operator;;;==,!=,<,<=,>,>=,in,not in;16..16;true -# Logical connectives, not, ternary, coalesce, arithmetic -condition;false;;bool;age > 1 and |;start;bool;;;12..12;true -condition;false;;bool;age > 1 or |;start;bool;;;11..11;true -condition;false;;bool;((age > 1) and |;start;bool;;;15..15;true -condition;false;;bool;age > 1 and (|;start;bool;;;13..13;true -condition;false;;bool;not |;start;bool;;;4..4;true -value;false;;status;age > 18 ? |;value;status;open=Open case,closed=Closed;;11..11;true -value;false;;status;age > 18 ? "open" : |;value;status;open=Open case,closed=Closed;;20..20;true -value;false;;number;amount ?? |;value;number;;;10..10;true -value;false;;number;age + |;start;number;;;6..6;true -value;false;;number;{ a: |;start;;;;5..5;true -value;false;;string;`Hello ${|;start;;;;9..9;true -# Member access -condition;false;;bool;customer.|;member;;;;9..9;false -condition;false;;bool;customer.na|;member;;;;9..11;false -condition;false;;bool;d().|;member;;;;4..4;false -condition;false;;bool;d("2024-01-01").|;member;;;;16..16;false -condition;false;;bool;some(items, #.|;member;;;;14..14;false -# Function arguments and closures -condition;false;;bool;d(|;argument;any;;;2..2;true -condition;false;;bool;d("2024-01-01", |;argument;string;;;16..16;true -condition;false;;bool;len(|;argument;any;;;4..4;true -condition;false;;bool;contains(name, |;argument;any;;;15..15;true -condition;false;;bool;startsWith(name, |;argument;string;;;17..17;true -condition;false;;bool;since.isAfter(|;argument;date;;;14..14;true -condition;false;;bool;since.diff(d(), |;argument;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;;16..16;true -condition;false;;bool;some(|;argument;any[];;;5..5;true -condition;false;;bool;some(items, |;closure;object;;;12..12;true -condition;false;;bool;filter(items, |;closure;object;;;14..14;true -condition;false;;bool;some(statuses, |;closure;status;open=Open case,closed=Closed;;15..15;true -condition;false;;bool;some(items, #.status == |;value;status;open=Open case,closed=Closed;;24..24;true -condition;false;;bool;some(statuses, # == |;value;status;open=Open case,closed=Closed;;20..20;true -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;;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;;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 -unary;true;$status;;"open", "closed", |;unaryStart;status;;!=,in,not in;18..18;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;;[|;listElement;status;open=Open case,closed=Closed;;1..1;true -unary;true;$status;;["open", |;listElement;status;closed=Closed;;9..9;true -unary;true;$status;;["open"] |;logical;bool;;and,or;9..9;true -unary;true;$status;;["open"], |;unaryStart;status;closed=Closed;!=,in,not in;10..10;true -unary;true;$status;;not in [|;listElement;status;open=Open case,closed=Closed;;8..8;true -unary;true;$status;;$ |;operator;;;==,!=,in,not in;2..2;true -unary;true;$status;;$ == |;value;status;open=Open case,closed=Closed;;5..5;true -unary;true;$number;;|;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;0..0;true -unary;true;$number;;> |;value;number;;;2..2;true -unary;true;$number;;> 5 |;logical;bool;;and,or;4..4;true -unary;true;$number;;5, |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;3..3;true -unary;true;$number;;-5 |;logical;bool;;and,or;3..3;true -unary;true;$number;;[1..|;range;number;;;4..4;false -unary;true;$number;;[1..5] |;logical;bool;;and,or;7..7;true -unary;true;$number;;(1..5) or |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;10..10;true -unary;true;$string;;len($) |;operator;;;==,!=,<,<=,>,>=,in,not in;7..7;true -unary;true;$string;;contains($, "a") |;logical;bool;;and,or;17..17;true -unary;true;$date;;|;unaryStart;date;;>,>=,<,<=,==,!=,in,not in;0..0;true -unary;true;$date;;> d(|;argument;any;;;4..4;true -unary;true;$date;;> d("|;inString;any;;;4..5;true -unary;true;$date;;[d()..|;range;date;;;6..6;false -unary;true;$date;;> d() |;logical;bool;;and,or;6..6;true -unary;true;$bool;;|;unaryStart;bool;;==,!=;0..0;true -unary;true;$bool;;== |;value;bool;;;3..3;true -unary;true;$any;;|;unaryStart;any;;>,>=,<,<=,==,!=,in,not in;0..0;true -# Path replace span covers only the last segment -path;false;;;grounding.stat|;path;;;;10..14;false -path;false;;;grounding.|;path;;;;10..10;false -path;false;;;grou|;path;;;;0..4;false -# Path role and lexer fallback -path;false;;;|;path;;;;0..0;true -path;false;;;customer.na|;path;;;;9..11;false -path;false;;;customer.na|me;path;;;;9..13;false -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;;>|;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 -unary;true;$number;;5|;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;0..1;true -unary;true;$number;;(|;range;number;;;1..1;false -unary;true;$number;;[1..5], (|;range;number;;;9..9;false -unary;true;$number;;1_000, |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;7..7;true -unary;true;$status;;"a", "b", |;unaryStart;status;open=Open case,closed=Closed;!=,in,not in;10..10;true -unary;true;$status;;"open" ,|;unaryStart;status;closed=Closed;!=,in,not in;8..8;true -unary;true;$status;;"open" o|;logical;bool;;and,or;7..8;true -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;;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 -unary;true;$string;;len($) > |;value;number;;;9..9;true -unary;true;$string;;upper($) == "|;inString;string;;;12..13;true -unary;true;$date;;d($) > d("|;inString;any;;;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;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 -unary;true;{"Object":{"$":{"Object":{"name":"String","status":{"Enum":["status",["open","closed"]]}}}}};;$.status == "|;inString;status;open=Open case,closed=Closed;;12..13;true -unary;true;{"Object":{"$":{"Object":{"name":"String","status":{"Enum":["status",["open","closed"]]}}}}};;$.|;member;;;;2..2;false -unary;true;$bool;;true |;logical;bool;;and,or;5..5;true -unary;true;$bool;;tr|;unaryStart;bool;;==,!=;0..2;true -unary;true;$any;;> |;value;any;;;2..2;true -unary;true;$number;;> 5 and < 10 or |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;16..16;true -# Adversarial: standard conditions -condition;false;;bool;customer.status == "|;inString;status;open=Open case,closed=Closed;;19..20;true -condition;false;;bool;customer.status in ["open", "|;inString;status;closed=Closed;;28..29;true -condition;false;;bool;not (customer.age > 18 and |;start;bool;;;27..27;true -condition;false;;bool;filter(items, #.status == "|;inString;status;open=Open case,closed=Closed;;26..27;true -condition;false;;bool;some(orders, #.sku == "|;inString;string;;;22..23;true -condition;false;;bool;len(items) > |;value;number;;;13..13;true -condition;false;;bool;upper(name) == "|;inString;string;;;15..16;true -condition;false;;bool;since.isAfter(d("|;inString;any;;;16..17;true -condition;false;;bool;d(since).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;;16..17;false -condition;false;;bool;age not in (|;range;number;;;12..12;false -condition;false;;bool;age in (|;range;number;;;8..8;false -condition;false;;bool;age not in [|;listElement;number;;;12..12;true -value;false;;string;`hello ${customer.|;member;;;;18..18;false -condition;false;;bool;customer.age == -|;value;number;;;17..17;true -condition;false;;bool;1_000 > |;value;number;;;8..8;true -condition;false;;bool;customer.address.city == "|;inString;string;;;25..26;true -condition;false;;bool;customer.address.|;member;;;;17..17;false -condition;false;;bool;items[0].status == "|;inString;status;open=Open case,closed=Closed;;19..20;true -condition;false;;bool;orders[0].|;member;;;;10..10;false -condition;false;;bool;age >= 18 and status == "|;inString;status;open=Open case,closed=Closed;;24..25;true -condition;false;;bool;(age > 1) == |;value;bool;;;13..13;true -condition;false;;bool;(name).|;member;;;;7..7;false -condition;false;;bool;tier == "gold" or tier == "|;inString;"gold" | "silver";gold,silver;;26..27;true -condition;false;;bool;status == "open" or |;start;bool;;;20..20;true -condition;false;;bool;customer.status ?? "|;inString;status;open=Open case,closed=Closed;;19..20;true -condition;false;;bool;statuses[1:|;start;number;;;11..11;true -condition;false;;bool;sum(items, #.|;member;;;;13..13;false -condition;false;;bool;map(items, #.price) |;operator;;;in,not in;20..20;true -condition;false;;bool;count(items, #.status == "open") > |;value;number;;;35..35;true -condition;false;;bool;name == `|;inString;string;;;8..9;true -condition;false;;bool;name == `a ${age}` |;logical;bool;;and,or;19..19;true -# Adversarial: output values -value;false;;status;"|;inString;status;open=Open case,closed=Closed;;0..1;true -value;false;;bool;tr|;value;bool;;;0..2;true -value;false;;date;|;value;date;;;0..0;true -value;false;;date;d(|;argument;any;;;2..2;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};["open", "|;inString;status;closed=Closed;;9..10;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ status: "|;inString;status;open=Open case,closed=Closed;;10..11;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ n: 1, status: |;value;status;open=Open case,closed=Closed;;16..16;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ "status": |;value;status;open=Open case,closed=Closed;;12..12;true -value;false;;status;age > 18 ? "open" : "|;inString;status;open=Open case,closed=Closed;;20..21;true -value;false;;status;age > 18 ? (active ? "open" : "|;inString;status;open=Open case,closed=Closed;;30..31;true -value;false;;status;status ?? "|;inString;status;open=Open case,closed=Closed;;10..11;true -value;false;;number;age > 18 ? 1 : |;value;number;;;15..15;true -value;false;;number;(|;start;number;;;1..1;true -value;false;;string;"|;inString;string;;;0..1;true -# Adversarial: caret mid-text -condition;false;;bool;status == "|" and age > 1;inString;status;open=Open case,closed=Closed;;10..12;true -condition;false;;bool;customer.| == 5;member;;;;9..9;false -condition;false;;bool;status in ["open", |"closed"];listElement;status;;;19..19;true -condition;false;;bool;status == "op|en";inString;status;open=Open case;;10..16;true -condition;false;;bool;age |> 5;operator;;;==,!=,<,<=,>,>=,in,not in;4..4;true -condition;false;;bool;age > 5 |and active;logical;bool;;and,or;8..8;true -condition;false;;bool;age > | and active;value;number;;;6..6;true -condition;false;;bool;age > |5 and active;value;number;;;6..6;true -condition;false;;bool;|age > 5;start;bool;;;0..0;true -unary;true;$status;;"open", |"closed";unaryStart;status;;!=,in,not in;8..8;true -# Adversarial: nasty input -condition;false;;bool;age > 1))) |;logical;bool;;and,or;11..11;true -condition;false;;bool;== |;value;;;;3..3;true -condition;false;;bool;status == ""|;logical;bool;;and,or;12..12;false -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;true_value == |;value;number;;;14..14;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;;;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 -unary;true;$status;;$ in [|;listElement;status;open=Open case,closed=Closed;;6..6;true -unary;true;$status;;$ in ["open", |;listElement;status;closed=Closed;;14..14;true -unary;true;$status;;["open", "closed"], |;unaryStart;status;;!=,in,not in;20..20;true -unary;true;$status;;"open" and not |;start;bool;;;15..15;true -unary;true;$number;;>= 5 and <= |;value;number;;;12..12;true -unary;true;$number;;> 5 or (|;range;number;;;8..8;false -unary;true;$number;;($ > 5 and |;start;bool;;;11..11;true -unary;true;$number;;($ > 5 and $ < 10) or |;unaryStart;number;;>,>=,<,<=,==,!=,in,not in;22..22;true -unary;true;$number;;[1..5], [|;listElement;number;;;9..9;true -unary;true;$number;;[1, 2, |;listElement;number;;;7..7;true -unary;true;$number;;in [1, |;listElement;number;;;7..7;true -unary;true;$number;;not in [1..|;range;number;;;11..11;false -unary;true;$number;;-|;value;number;;;1..1;true -unary;true;$number;;- |;value;number;;;2..2;true -unary;true;$number;;5 |;logical;bool;;and,or;2..2;true -unary;true;$number;;5, 6 |;logical;bool;;and,or;5..5;true -unary;true;$number;;5 - |;start;number;;;4..4;true -unary;true;$number;;> 5 an|;logical;bool;;and,or;4..6;true -unary;true;$number;;> 5 andrew|;logical;bool;;and,or;4..10;true -unary;true;$number;;len(|;argument;any;;;4..4;true -unary;true;$number;;abs($) > |;value;number;;;9..9;true -unary;true;$number;;true |;logical;bool;;and,or;5..5;true -unary;true;$string;;"a", "b" |;logical;bool;;and,or;9..9;true -unary;true;$string;;$ == "|;inString;string;;;5..6;true -unary;true;$string;;$ in ["a", "|;inString;string;;;11..12;true -unary;true;$string;;matches($, "|;inString;string;;;11..12;false -unary;true;$string;;$ == upper(|;argument;string;;;11..11;true -unary;true;$string;;len($) in [|;listElement;number;;;11..11;true -unary;true;$date;;$ > d("2024-01-01") and $ < d(|;argument;any;;;30..30;true -unary;true;$date;;> d("2024-01-01"|;operator;;;==,!=,in,not in;16..16;false -unary;true;$date;;> d("2024-01-01").add(1, "day") |;logical;bool;;and,or;32..32;true -unary;true;$date;;$.isAfter(|;argument;date;;;10..10;true -unary;true;$date;;$.isAfter(d(|;argument;any;;;12..12;true -unary;true;$date;;[d("2024-01-01")..d(|;argument;any;;;20..20;true -unary;true;$date;;in [d("2024-01-01")..|;range;date;;;21..21;false -unary;true;$date;;> d().startOf("day") |;logical;bool;;and,or;21..21;true -unary;true;$bool;;== tr|;value;bool;;;3..5;true -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,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 -unary;true;{"Object":{"$":{"Array":{"Enum":["status",["open","closed"]]}}}};;contains($, "|;inString;status;open=Open case,closed=Closed;;12..13;false -unary;true;{"Object":{"$":{"Array":{"Enum":["status",["open","closed"]]}}}};;some($, # in ["|;inString;status;open=Open case,closed=Closed;;14..15;true -unary;true;{"Object":{"$":{"Array":{"Enum":["status",["open","closed"]]}}}};;len($) > |;value;number;;;9..9;true -# Adversarial: standard conditions, second batch -condition;false;;bool;some(statuses, # in [|;listElement;status;open=Open case,closed=Closed;;21..21;true -condition;false;;bool;some(statuses, # in ["open", |;listElement;status;closed=Closed;;29..29;true -condition;false;;bool;some(statuses, # not in [|;listElement;status;open=Open case,closed=Closed;;25..25;true -condition;false;;bool;some(statuses, # == "open" or # == |;value;status;open=Open case,closed=Closed;;35..35;true -condition;false;;bool;some(items, #.status in [|;listElement;status;open=Open case,closed=Closed;;25..25;true -condition;false;;bool;some(items, #.status == "open" and #.|;member;;;;37..37;false -condition;false;;bool;some(items, #.status == "open") and |;start;bool;;;36..36;true -condition;false;;bool;some(items, #.status == "open") |;logical;bool;;and,or;32..32;true -condition;false;;bool;all(items, #.price > |;value;number;;;21..21;true -condition;false;;bool;none(items, #.price in [1..|;range;number;;;27..27;false -condition;false;;bool;one(tags, # == "|;inString;string;;;15..16;true -condition;false;;bool;filter(items, #.price > 1)[0].|;member;;;;30..30;false -condition;false;;bool;len(filter(items, #.price > 1)) > |;value;number;;;34..34;true -condition;false;;bool;map(items, #.status)[0] == "|;inString;status;open=Open case,closed=Closed;;27..28;true -condition;false;;bool;contains(tags, "|;inString;string;;;15..16;false -condition;false;;bool;contains(statuses, "|;inString;status;open=Open case,closed=Closed;;19..20;false -condition;false;;bool;contains(name, "|;inString;any;;;15..16;false -condition;false;;bool;"open" == |;value;string;;;10..10;true -condition;false;;bool;"open" == status |;logical;bool;;and,or;17..17;true -condition;false;;bool;status == "open" ? active : |;start;bool;;;28..28;true -condition;false;;bool;(status ?? "open") == "|;inString;status;open=Open case,closed=Closed;;22..23;true -condition;false;;bool;status ?? "open" == "|;inString;string;;;20..21;true -condition;false;;bool;customer.status != "open" and customer.status != "|;inString;status;open=Open case,closed=Closed;;49..50;true -condition;false;;bool;since > d("2024-01-01").add(1, "day") and since < d(|;argument;any;;;52..52;true -condition;false;;bool;since.diff(d(), "days") > |;value;number;;;26..26;true -condition;false;;bool;since.isSame(|;argument;date;;;13..13;true -condition;false;;bool;since.format("|;inString;string;;;13..14;false -condition;false;;bool;since.year() == |;value;number;;;16..16;true -condition;false;;bool;since.year() |;operator;;;==,!=,<,<=,>,>=,in,not in;13..13;true -condition;false;;bool;d(since).|;member;;;;9..9;false -condition;false;;bool;age + 1 > |;value;number;;;10..10;true -condition;false;;bool;age * 2 |;operator;;;==,!=,<,<=,>,>=,in,not in;8..8;true -condition;false;;bool;age > age * |;start;number;;;12..12;true -condition;false;;bool;name + " " |;operator;;;==,!=,in,not in;11..11;true -condition;false;;bool;string(age) == "|;inString;string;;;15..16;true -condition;false;;bool;number(name) > |;value;number;;;15..15;true -condition;false;;bool;age in [18, |;listElement;number;;;12..12;true -condition;false;;bool;age in [18, 21] |;logical;bool;;and,or;16..16;true -condition;false;;bool;age in [18..|;range;number;;;12..12;false -condition;false;;bool;age in (18..|;range;number;;;12..12;false -condition;false;;bool;since in [d("2024-01-01")..|;range;date;;;27..27;false -condition;false;;bool;since in [d("2024-01-01")..d(|;argument;any;;;29..29;true -condition;false;;bool;age not in [1..5] and |;start;bool;;;22..22;true -condition;false;;bool;age >= 18 and (status == "open" or status == "|;inString;status;open=Open case,closed=Closed;;45..46;true -condition;false;;bool;age >= 18 and (status == "open" or |;start;bool;;;35..35;true -condition;false;;bool;age >= 18 and (status == "open") |;logical;bool;;and,or;33..33;true -condition;false;;bool;(age >= 18 and status == "open") or |;start;bool;;;36..36;true -condition;false;;bool;not active and |;start;bool;;;15..15;true -condition;false;;bool;not (active) |;logical;bool;;and,or;13..13;true -condition;false;;bool;active == true and |;start;bool;;;19..19;true -condition;false;;bool;tier == 'gold' and tier != '|;inString;"gold" | "silver";gold,silver;;27..28;true -condition;false;;bool;mood == "😀 Happy" and mood == "|;inString;mood;happy=😀 Happy,sad=😢 Sad;;33..34;true -condition;false;;bool;amount > |;value;number?;;;9..9;true -condition;false;;bool;amount ?? 0 > |;value;number;;;14..14;true -condition;false;;bool;(amount ?? 0) > |;value;number;;;16..16;true -condition;false;;bool;$root.customer.status in [|;listElement;status;open=Open case,closed=Closed;;26..26;true -condition;false;;bool;status == "open|";inString;status;open=Open case;;10..16;true -condition;false;;bool;status == "open" and (|;start;bool;;;22..22;true -condition;false;;bool;status == "open" and ((|;start;bool;;;23..23;true -condition;false;;bool;status == "open" and (age > 1 or (|;start;bool;;;34..34;true -condition;false;;bool;age == 1 + |;start;number;;;11..11;true -condition;false;;bool;age == (|;start;number;;;8..8;true -condition;false;;bool;age == -(|;start;number;;;9..9;true -condition;false;;bool;name == "a" + |;start;string;;;14..14;true -condition;false;;bool;`${|;start;;;;3..3;true -condition;false;;bool;`${status == "|;inString;status;open=Open case,closed=Closed;;13..14;true -condition;false;;bool;`${age} |;inString;bool;;;0..8;false -# Adversarial: output values, second batch -value;false;;status;`|;inString;status;open=Open case,closed=Closed;;0..1;true -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;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 -value;false;;status;customer.status |;operator;;;==,!=,in,not in;16..16;true -value;false;;status;"open" |;operator;;;==,!=,in,not in;7..7;true -value;false;;status;upper(|;argument;string;;;6..6;true -value;false;;bool;age > 18 ? |;value;bool;;;11..11;true -value;false;;bool;age > 18 |;logical;bool;;and,or;9..9;true -value;false;;bool;age > |;value;number;;;6..6;true -value;false;;bool;age |;operator;;;==,!=,<,<=,>,>=,in,not in;4..4;true -value;false;;bool;active and |;start;bool;;;11..11;true -value;false;;number;age > 18 ? 1 : (|;start;number;;;16..16;true -value;false;;number;-|;value;number;;;1..1;true -value;false;;number;1_000 + |;start;number;;;8..8;true -value;false;;number;sum(map(items, #.|;member;;;;17..17;false -value;false;;number;sum(map(items, #.price)) + |;start;number;;;27..27;true -value;false;;number;round(amount ?? 0, |;argument;number;;;19..19;true -value;false;;number;len(|;argument;any;;;4..4;true -value;false;;number;age ?? |;value;number;;;7..7;true -value;false;;date;d("|;inString;any;;;2..3;true -value;false;;date;d().add(|;argument;any;;;8..8;true -value;false;;date;d().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;;11..12;false -value;false;;date;since.startOf("d|;inString;DurationUnit;days,day,d;;14..16;false -value;false;;date;active ? since : |;value;date;;;17..17;true -value;false;;date;since ?? |;value;date;;;9..9;true -value;false;;string;name ?? "|;inString;string;;;8..9;true -value;false;;string;`Hello ${name}, you are ${|;start;;;;26..26;true -value;false;;string;upper(name) + " " + |;start;string;;;20..20;true -value;false;;string;age > 18 ? "adult" : "|;inString;string;;;21..22;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};|;value;status[];open=Open case,closed=Closed;;0..0;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};["open", "closed"] |;operator;;;in,not in;19..19;true -value;false;;{"Array":{"Enum":["status",["open","closed"]]}};filter(statuses, # == "|;inString;status;open=Open case,closed=Closed;;22..23;true -value;false;;{"Array":"Number"};[1, |;listElement;number;;;4..4;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ n: |;value;number;;;5..5;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ x: |;start;;;;5..5;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ status: "open", |;start;;;;18..18;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ status: "open", n: |;value;number;;;21..21;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ status: age > 1 ? "|;inString;status;open=Open case,closed=Closed;;20..21;true -value;false;;{"Object":{"inner":{"Object":{"status":{"Enum":["status",["open","closed"]]}}}}};{ inner: { status: "|;inString;status;open=Open case,closed=Closed;;19..20;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{ status: |;value;status;open=Open case,closed=Closed;;10..10;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]},"n":"Number"}};{|;start;;;;1..1;true -value;false;;{"Enum":[null,["a","b"]]};|;value;"a" | "b";a,b;;0..0;true -value;false;;{"Const":"fixed"};|;value;"fixed";fixed;;0..0;true -value;false;;{"Nullable":{"Enum":["status",["open","closed"]]}};|;value;status?;open=Open case,closed=Closed,null;;0..0;true -value;false;;{"Nullable":{"Enum":["status",["open","closed"]]}};"|;inString;status?;open=Open case,closed=Closed;;0..1;true -# Adversarial: caret mid-text, second batch -condition;false;;bool;status in ["open", "|closed"];inString;status;closed=Closed;;19..27;true -condition;false;;bool;status in [|"open"];listElement;status;closed=Closed;;11..11;true -condition;false;;bool;status in ["open"|];operator;;;==,!=,in,not in;17..17;false -condition;false;;bool;status == "open" |and age > 1;logical;bool;;and,or;17..17;true -condition;false;;bool;status == "open" a|nd age > 1;logical;bool;;and,or;17..20;true -condition;false;;bool;status == "open" and |age > 1;start;bool;;;21..21;true -condition;false;;bool;status == "open" and a|ge > 1;start;bool;;;21..24;false -condition;false;;bool;status ==| "open";value;status;open=Open case,closed=Closed;;9..9;true -condition;false;;bool;status =|= "open";operator;;;==,!=,in,not in;8..8;false -condition;false;;bool;cust|omer.status == "open";start;bool;;;0..8;false -condition;false;;bool;customer.st|atus == "open";member;;;;9..15;false -condition;false;;bool;d(|) > since;argument;any;;;2..2;true -condition;false;;bool;some(items, |#.price > 1);closure;object;;;12..12;true -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;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 -condition;false;;bool;age > 1) and |;start;bool;;;13..13;true -condition;false;;bool;age > 1) and status == "|;inString;status;open=Open case,closed=Closed;;23..24;true -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;;;;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 -condition;false;;bool;or status == "|;inString;status;open=Open case,closed=Closed;;13..14;true -condition;false;;bool;, |;start;;;;2..2;true -condition;false;;bool;status == "open",|;start;;;;17..17;true -condition;false;;bool;status == "open" and age > 18 and name == "Zoë" and mood == "|;inString;mood;happy=😀 Happy,sad=😢 Sad;;61..62;true -condition;false;;bool;name == "it's" and status == "|;inString;status;open=Open case,closed=Closed;;29..30;true -condition;false;;bool;name == 'say "hi"' and status == '|;inString;status;open=Open case,closed=Closed;;33..34;true -condition;false;;bool;name == "a" "b" |;logical;bool;;and,or;16..16;true -condition;false;;bool;status == 'open" and age > |;inString;status;;;10..27;true -condition;false;;bool;inbound |;operator;;;==,!=,<,<=,>,>=,in,not in;8..8;true -condition;false;;bool;inbound in [|;listElement;number;;;12..12;true -condition;false;;bool;andrew |;operator;;;==,!=,in,not in;7..7;true -condition;false;;bool;nothing == |;value;bool;;;11..11;true -condition;false;;bool;true_value |;operator;;;==,!=,<,<=,>,>=,in,not in;11..11;true -condition;false;;bool;in_progress == |;value;bool;;;15..15;true -condition;false;;bool;customer.in_progress |;operator;;;==,!=,<,<=,>,>=,in,not in;21..21;true -condition;false;;bool;notin |;operator;;;==,!=,<,<=,>,>=,in,not in;6..6;true -condition;false;;bool;age not in|;operator;;;==,!=,<,<=,>,>=,in,not in;4..10;true -condition;false;;bool;age not |;start;bool;;;8..8;true -condition;false;;bool;age in|;operator;;;==,!=,<,<=,>,>=,in,not in;4..6;true -condition;false;;bool;age i|;operator;;;==,!=,<,<=,>,>=,in,not in;4..5;true -condition;false;;bool;age >= 18 and status i|;operator;;;==,!=,in,not in;21..22;true -condition;false;;bool;status == "open" an|;logical;bool;;and,or;17..19;true -condition;false;;bool;status == "open" andrew|;logical;bool;;and,or;17..23;true -condition;false;;bool;null == |;value;null;;;8..8;true -condition;false;;bool;status == null |;logical;bool;;and,or;15..15;true -condition;false;;bool;status == |null;value;status;open=Open case,closed=Closed;;10..10;true -condition;false;;bool;x = status == "|;inString;status;open=Open case,closed=Closed;;14..15;true -condition;false;;bool;age > 1 and (|);start;bool;;;13..13;true -condition;false;;bool;status == "open" and status == "|" and age > 1;inString;status;open=Open case,closed=Closed;;31..33;true -# 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;;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;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;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;;;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 -unary;true;$string;;not startsWith($, "|;inString;string;;;18..19;false -unary;true;$date;;== d(|;argument;any;;;5..5;true -unary;true;$date;;d(|;argument;any;;;2..2;true -unary;true;$date;;d("2024-01-01") |;logical;bool;;and,or;16..16;true -unary;true;$date;;d("2024-01-01"), |;unaryStart;date;;>,>=,<,<=,==,!=,in,not in;17..17;true -unary;true;$date;;[d("2024-01-01")..d("2024-12-31")] |;logical;bool;;and,or;35..35;true -unary;true;$date;;> d("2024-01-01") and < d("|;inString;any;;;26..27;true -unary;true;$date;;d().startOf("day") |;logical;bool;;and,or;19..19;true -unary;true;$bool;;false |;logical;bool;;and,or;6..6;true -unary;true;$bool;;!= |;value;bool;;;3..3;true -unary;true;$any;;"a", |;unaryStart;any;;>,>=,<,<=,==,!=,in,not in;5..5;true -unary;true;$any;;== |;value;any;;;3..3;true -condition;false;;bool;age > 1.|;value;number;;;6..8;true -condition;false;;bool;age in [1, 2, 3] and status in ["open", |;listElement;status;closed=Closed;;40..40;true -condition;false;;bool;since > d("2024-01-01") and since < d("|;inString;any;;;38..39;true -condition;false;;bool;startsWith(name, "a") and |;start;bool;;;26..26;true -condition;false;;bool;not startsWith(name, "|;inString;string;;;21..22;false -condition;false;;bool;len(tags) == 0 or tags[0] == "|;inString;string;;;29..30;true -condition;false;;bool;tags[0] |;operator;;;==,!=,in,not in;8..8;true -condition;false;;bool;items[0].|;member;;;;9..9;false -condition;false;;bool;items[-1].|;member;;;;10..10;false -condition;false;;bool;items[0].price > |;value;number;;;17..17;true -condition;false;;bool;customer.status == "open" and customer.since > d("2024-01-01") and customer.age in [|;listElement;number;;;84..84;true -condition;false;;bool;status == "open" or status == "closed" or status == "|;inString;status;open=Open case,closed=Closed;;52..53;true -condition;false;;bool;customer.status in ["open", "closed"] |;logical;bool;;and,or;38..38;true -condition;false;;bool;len(name) > 3 and upper(name) == "|;inString;string;;;33..34;true -condition;false;;bool;abs(age - 18) < |;value;number;;;16..16;true -condition;false;;bool;age > 18 and (tier == "gold" or tier == "silver") and status == "|;inString;status;open=Open case,closed=Closed;;64..65;true -condition;false;;bool;customer.address.city |;operator;;;==,!=,in,not in;22..22;true -condition;false;;bool;some(items, some(statuses, # == "|;inString;status;open=Open case,closed=Closed;;32..33;true -condition;false;;bool;some(items, #.price > 1 and some(tags, # == "|;inString;string;;;44..45;true -condition;false;;bool;tr|;start;bool;;;0..2;false -condition;false;;bool;true|;start;bool;;;0..4;false -condition;false;;bool;true |;logical;bool;;and,or;5..5;true -condition;false;;bool;null|;start;bool;;;0..4;false -condition;false;;bool;age == null|;value;number;;;7..11;true -condition;false;;bool;age in [1..5]|;logical;bool;;and,or;13..13;false -condition;false;;bool;age in [1..5] and age not in (|;range;number;;;30..30;false -condition;false;;bool;age > | garbage here;value;number;;;6..6;true -condition;false;;bool;status == "|garbage;inString;status;open=Open case,closed=Closed;;10..18;true -condition;false;;bool;some(items, #.status == "open"|);logical;bool;;and,or;30..30;false -condition;false;;bool;status == "open" and age > 1|8;value;number;;;27..29;true -condition;false;;bool;status == "open" and and |;start;bool;;;25..25;true -condition;false;;bool;age > 1 and and status == "|;inString;status;open=Open case,closed=Closed;;26..27;true -condition;false;;bool;(((|;start;bool;;;3..3;true -condition;false;;bool;age <> |;value;;;;7..7;true -condition;false;;bool;age > 1 && |;start;bool;;;11..11;true -condition;false;;bool;status == “|;start;bool;;;13..13;true -condition;false;;bool;age > 1 and status == "open|" and;inString;status;open=Open case;;22..28;true -value;false;;number;age > 18 ? age : |;value;number;;;17..17;true -value;false;;status;status == "open" ? "closed" : |;value;status;open=Open case,closed=Closed;;30..30;true -value;false;;string;name == "" ? "unknown" : |;value;string;;;25..25;true -value;false;;bool;not |;start;bool;;;4..4;true -value;false;;bool;age > 18 and status == "|;inString;status;open=Open case,closed=Closed;;23..24;true -value;false;;number;len(filter(items, #.status == "|;inString;status;open=Open case,closed=Closed;;30..31;true -value;false;;number;sum(map(items, #.price)) |;operator;;;==,!=,<,<=,>,>=,in,not in;25..25;true -value;false;;number;max(|;argument;any[];;;4..4;true -value;false;;{"Object":{"status":{"Enum":["status",["open","closed"]]}}};{ status: status ?? "|;inString;status;open=Open case,closed=Closed;;20..21;true -value;false;;{"Array":{"Object":{"status":{"Enum":["status",["open","closed"]]}}}};[{ status: "|;inString;status;open=Open case,closed=Closed;;11..12;true -value;false;;{"Array":{"Object":{"status":{"Enum":["status",["open","closed"]]}}}};[{ status: "open" }, { status: |;value;status;open=Open case,closed=Closed;;31..31;true -path;false;;;customer.address.|;path;;;;17..17;false -path;false;;;cust|;path;;;;0..4;false -path;false;;;|customer;path;;;;0..8;false -path;false;;;in|;path;;;;0..2;false -path;false;;;customer.|address;path;;;;9..16;false - -# Path role: only a bare identifier chain is a path, anything else classifies as a value expression -path;false;;;d(|;argument;any;;;2..2;true -path;false;;;d("|;inString;any;;;2..3;true -path;false;;;customer.since.|;path;;;;15..15;false -path;false;;;customer.since.isAfter(|;argument;date;;;23..23;true -path;false;;;customer.name |;operator;;;==,!=,in,not in;14..14;true -path;false;;;customer.status ?? "|;inString;status;open=Open case,closed=Closed;;19..20;true -path;false;;;customer.status ?? "open" |;operator;;;==,!=,in,not in;26..26;true -path;false;;;customer.status == "|;inString;status;open=Open case,closed=Closed;;19..20;true -path;false;;;customer.status == "open" |;logical;bool;;and,or;26..26;true -path;false;;;len(|;argument;any;;;4..4;true -path;false;;;customer.age + |;start;number;;;15..15;true - -# Closure membership: the collection literal takes the enum of the field it is tested against -condition;false;;bool;some([|], # in statuses);listElement;status;open=Open case,closed=Closed;;6..6;true -condition;false;;bool;some(["open", |], # in statuses);listElement;status;closed=Closed;;14..14;true -condition;false;;bool;some([|] as s, s in statuses);listElement;status;open=Open case,closed=Closed;;6..6;true -condition;false;;bool;some([|], # in tags);listElement;;;;6..6;true - -# Auto-open: an operand due (empty editor or after something) opens the menu; a word under the caret or a member/path prefix does not -condition;false;;bool;active and |;start;bool;;;11..11;true -condition;false;;bool;a > 1 or |;start;bool;;;9..9;true -value;false;;number;x ? |;value;number;;;4..4;true -value;false;;number;x ?? |;value;any;;;5..5;true -value;false;;;[|;listElement;;;;1..1;true -condition;false;;bool;(|;start;bool;;;1..1;true -condition;false;;bool;cust|;start;bool;;;0..4;false -path;false;;; |;path;;;;1..1;true -path;false;;;customer.|;path;;;;9..9;false - -# Const-typed fields offer their literal like a one-member enum; a bare literal on the left still widens -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something == |;value;"hello";hello;;13..13;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something != |;value;"hello";hello;;13..13;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something == "|;inString;"hello";hello;;13..14;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something == "he|;inString;"hello";hello;;13..16;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something in [|;listElement;"hello";hello;;14..14;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something in ["hello", |;listElement;"hello";;;23..23;true -condition;false;{"Object":{"something":{"Const":"hello"}}};bool;something |;operator;;;==,!=,in,not in;10..10;true -condition;false;{"Object":{"something":{"Nullable":{"Const":"hello"}}}};bool;something == |;value;"hello"?;hello,null;;13..13;true -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 -unary;true;{"Object":{"$":{"Const":"hello"}}};;|;unaryStart;"hello";hello;!=,in,not in;0..0;true -# Nullable operands: inner operators plus ==, != and ??; equality values offer null -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault |;logical;bool;;and,or,==,!=,??;18..18;true -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault == |;value;bool?;null;;21..21;true -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault == nu|;value;bool?;null;;21..23;true -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault != |;value;bool?;null;;21..21;true -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault ?? |;start;bool;;;21..21;true -condition;false;{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}};bool;aircraft.apuFault == null |;logical;bool;;and,or;26..26;true -condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status == |;value;status?;open=Open case,closed=Closed,null;;10..10;true -condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status == "|;inString;status?;open=Open case,closed=Closed;;10..11;true -condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status in [|;listElement;status;open=Open case,closed=Closed;;11..11;true -condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status |;operator;;;==,!=,in,not in,??;7..7;true -condition;false;{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}};bool;status ?? |;value;status;open=Open case,closed=Closed;;10..10;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 -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/data/slots.toml b/core/expression/tests/data/slots.toml new file mode 100644 index 00000000..ce24b85b --- /dev/null +++ b/core/expression/tests/data/slots.toml @@ -0,0 +1,509 @@ +# Start and values + +[[test]] +expression = "|" +slot = { state = "start", type = "bool", span = "0..0" } + +[[test]] +expression = "ag|" +slot = { state = "start", type = "bool", span = "0..2", auto_open = false } + +[[test]] +expression = "|" +role = "value" +expected = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "0..0" } + +[[test]] +expression = "age == |" +slot = { state = "value", type = "number", options = [], span = "7..7" } + +[[test]] +expression = "since < |" +slot = { state = "value", type = "date", options = [], span = "8..8" } + +[[test]] +expression = "status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "tier == |" +slot = { state = "value", type = "\"gold\" | \"silver\"", options = ["gold", "silver"], span = "8..8" } + +[[test]] +expression = "status in |" +slot = { state = "value", type = "status[]", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "age > -|" +slot = { state = "value", type = "number", options = [], span = "7..7" } + +[[test]] +expression = "(age > 1 and (status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "24..24" } + +# Inside strings + +[[test]] +expression = "status == \"op|" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "10..13" } + +[[test]] +expression = "status == \"clo|sed\"" +slot = { state = "inString", type = "status", options = ["closed=Closed"], span = "10..18" } + +[[test]] +expression = "status in ['op|" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "11..14" } + +[[test]] +expression = "mood == \"😀|" +slot = { state = "inString", type = "mood", options = ["happy=😀 Happy"], span = "8..13" } + +[[test]] +expression = "startsWith(name, \"|" +slot = { state = "inString", type = "string", options = [], span = "17..18", auto_open = false } + +[[test]] +expression = "`Hel|lo`" +role = "value" +expected = "string" +slot = { state = "inString", type = "string", options = [], span = "0..7" } + +# Lists and ranges + +[[test]] +expression = "status in [|" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "11..11" } + +[[test]] +expression = "status in [\"open\", |" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "19..19" } + +[[test]] +expression = "status in [\"open\", \"closed\", |" +slot = { state = "listElement", type = "status", options = [], span = "29..29" } + +[[test]] +expression = "age in [1..|" +slot = { state = "range", type = "number", span = "11..11", auto_open = false } + +[[test]] +expression = "since in [d()..|" +slot = { state = "range", type = "date", span = "15..15", auto_open = false } + +[[test]] +expression = "items[|" +slot = { state = "start", type = "number", span = "6..6" } + +[[test]] +expression = "some([|], # in statuses)" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "6..6" } + +[[test]] +expression = "some([\"open\", |], # in statuses)" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "14..14" } + +# Operators after an operand + +[[test]] +expression = "age |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "4..4" } + +[[test]] +expression = "name |" +slot = { state = "operator", operators = ["==", "!=", "in", "not in"], span = "5..5" } + +[[test]] +expression = "tags |" +slot = { state = "operator", operators = ["in", "not in"], span = "5..5" } + +[[test]] +expression = "amount |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in", "??"], span = "7..7" } + +[[test]] +expression = "d() |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "4..4" } + +[[test]] +expression = "len(x) |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "7..7" } + +[[test]] +expression = "age >|" +slot = { state = "operator", operators = [">", ">="], span = "4..5" } + +[[test]] +expression = "age <=|" +slot = { state = "value", type = "number", options = [], span = "6..6" } + +[[test]] +expression = "claim.amount =|" +slot = { state = "operator", operators = ["=="], span = "13..14" } + +# Logical after a condition + +[[test]] +expression = "active |" +slot = { state = "logical", type = "bool", operators = ["and", "or", "==", "!="], span = "7..7" } + +[[test]] +expression = "not active |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "11..11" } + +[[test]] +expression = "age > 1 |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "8..8" } + +[[test]] +expression = "status == \"closed\"|" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "18..18", auto_open = false } + +[[test]] +expression = "age > 1 an|" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "8..10" } + +[[test]] +expression = "age > 1 and age |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "16..16" } + +[[test]] +expression = "since > d().startOf(\"day\") |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "27..27" } + +# Connectives, ternary, coalesce, arithmetic + +[[test]] +expression = "age > 1 and |" +slot = { state = "start", type = "bool", span = "12..12" } + +[[test]] +expression = "not |" +slot = { state = "start", type = "bool", span = "4..4" } + +[[test]] +expression = "age > 18 ? |" +role = "value" +expected = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "11..11" } + +[[test]] +expression = "amount ?? |" +role = "value" +expected = "number" +slot = { state = "value", type = "number", options = [], span = "10..10" } + +[[test]] +expression = "age + |" +role = "value" +expected = "number" +slot = { state = "start", type = "number", span = "6..6" } + +[[test]] +expression = "{ a: |" +role = "value" +expected = "number" +slot = { state = "start", span = "5..5" } + +# Member access + +[[test]] +expression = "customer.|" +slot = { state = "member", span = "9..9", auto_open = false } + +[[test]] +expression = "customer.na|" +slot = { state = "member", span = "9..11", auto_open = false } + +[[test]] +expression = "d().|" +slot = { state = "member", span = "4..4", auto_open = false } + +[[test]] +expression = "some(items, #.|" +slot = { state = "member", span = "14..14", auto_open = false } + +# Function arguments and closures + +[[test]] +expression = "d(|" +slot = { state = "argument", type = "any", span = "2..2" } + +[[test]] +expression = "d(\"2024-01-01\", |" +slot = { state = "argument", type = "string", span = "16..16" } + +[[test]] +expression = "startsWith(name, |" +slot = { state = "argument", type = "string", span = "17..17" } + +[[test]] +expression = "since.isAfter(|" +slot = { state = "argument", type = "date", span = "14..14" } + +[[test]] +expression = "some(|" +slot = { state = "argument", type = "any[]", span = "5..5" } + +[[test]] +expression = "some(items, |" +slot = { state = "closure", type = "object", options = [], span = "12..12" } + +[[test]] +expression = "some(statuses, |" +slot = { state = "closure", type = "status", options = ["open=Open case", "closed=Closed"], span = "15..15" } + +[[test]] +expression = "some(items, #.status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "24..24" } + +[[test]] +expression = "some(statuses as s, s == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "25..25" } + +[[test]] +expression = "some(items as x, some(statuses as s, |" +slot = { state = "closure", type = "status", options = ["open=Open case", "closed=Closed"], span = "37..37" } + +[[test]] +expression = "some(items as x, x|" +slot = { state = "closure", type = "object", options = [], span = "17..18", auto_open = false } + +# Unary cells + +[[test]] +expression = "|" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["open=Open case", "closed=Closed"], operators = ["!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "\"op|" +subject = "status" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "0..3" } + +[[test]] +expression = "\"open\", |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["closed=Closed"], operators = ["!=", "in", "not in"], span = "8..8" } + +[[test]] +expression = "\"open\" or |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["closed=Closed"], operators = ["!=", "in", "not in"], span = "10..10" } + +[[test]] +expression = "\"open\", \"closed\", |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = [], operators = ["!=", "in", "not in"], span = "18..18" } + +[[test]] +expression = "!= |" +subject = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "3..3" } + +[[test]] +expression = "[\"open\", |" +subject = "status" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "9..9" } + +[[test]] +expression = "not in [|" +subject = "status" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "8..8" } + +[[test]] +expression = "$ |" +subject = "status" +slot = { state = "operator", operators = ["==", "!=", "in", "not in"], span = "2..2" } + +[[test]] +expression = "|" +subject = "number" +slot = { state = "unaryStart", type = "number", options = [], operators = [">", ">=", "<", "<=", "==", "!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "> 5 |" +subject = "number" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "4..4" } + +[[test]] +expression = "[1..|" +subject = "number" +slot = { state = "range", type = "number", span = "4..4", auto_open = false } + +[[test]] +expression = "<|" +subject = "number" +slot = { state = "operator", operators = ["<", "<="], span = "0..1" } + +[[test]] +expression = ">= 1 and >| 2" +subject = "number" +slot = { state = "operator", operators = [">", ">="], span = "9..10" } + +[[test]] +expression = "|" +subject = "date" +slot = { state = "unaryStart", type = "date", options = [], operators = [">", ">=", "<", "<=", "==", "!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "|" +subject = "bool" +slot = { state = "unaryStart", type = "bool", options = [], operators = ["==", "!="], span = "0..0" } + +# Path cells + +[[test]] +expression = "grounding.stat|" +role = "path" +slot = { state = "path", span = "10..14", auto_open = false } + +[[test]] +expression = "|" +role = "path" +slot = { state = "path", span = "0..0" } + +[[test]] +expression = "customer.na|me" +role = "path" +slot = { state = "path", span = "9..13", auto_open = false } + +[[test]] +expression = "d(|" +role = "path" +slot = { state = "argument", type = "any", span = "2..2" } + +[[test]] +expression = "customer.status == \"|" +role = "path" +slot = { state = "inString", type = "status", options = ["open=Open case", "closed=Closed"], span = "19..20" } + +# Malformed input + +[[test]] +expression = "age > 1))) |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "11..11" } + +[[test]] +expression = "== |" +slot = { state = "value", options = [], span = "3..3" } + +[[test]] +expression = "status == \"open\" \"|" +slot = { state = "inString", type = "bool", options = [], span = "17..18" } + +[[test]] +expression = "in|" +slot = { state = "start", type = "bool", span = "0..2", auto_open = false } + +[[test]] +expression = "age > 1 and or |" +slot = { state = "start", type = "bool", span = "15..15" } + +# Const and nullable fields + +[[test]] +expression = "something == |" +scope = '{"Object":{"something":{"Const":"hello"}}}' +slot = { state = "value", type = "\"hello\"", options = ["hello"], span = "13..13" } + +[[test]] +expression = "something in [\"hello\", |" +scope = '{"Object":{"something":{"Const":"hello"}}}' +slot = { state = "listElement", type = "\"hello\"", options = [], span = "23..23" } + +[[test]] +expression = "something == |" +scope = '{"Object":{"something":{"Nullable":{"Const":"hello"}}}}' +slot = { state = "value", type = "\"hello\"?", options = ["hello", "null"], span = "13..13" } + +[[test]] +expression = "status == |" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "value", type = "status?", options = ["open=Open case", "closed=Closed", "null"], span = "10..10" } + +[[test]] +expression = "status ?? |" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "status ?|" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "operator", operators = ["??"], span = "7..8" } + +[[test]] +expression = "aircraft.apuFault |" +scope = '{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}}' +slot = { state = "logical", type = "bool", operators = ["and", "or", "==", "!=", "??"], span = "18..18" } + +[[test]] +expression = "aircraft.apuFault == |" +scope = '{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}}' +slot = { state = "value", type = "bool?", options = ["null"], span = "21..21" } + +[[test]] +expression = "amount == |" +slot = { state = "value", type = "number?", options = ["null"], span = "10..10" } +# Literal facts + +[[test]] +expression = "grade in ['a', 'b', 'c']" +literals = ["'a' enum a,b,c,d", "'b' enum a,b,c,d", "'c' enum a,b,c,d"] + +[[test]] +expression = "grade in ['a', 'b', 'c', |" +slot = { state = "listElement", type = "grade", options = ["d"], span = "25..25" } +literals = ["'a' enum a,b,c,d", "'b' enum a,b,c,d", "'c' enum a,b,c,d"] + +[[test]] +expression = "grade in ['a', 'x']" +literals = ["'a' enum a,b,c,d", "'x' enum a,b,c,d invalid"] + +[[test]] +expression = "status == \"open\" and customer.status != 'closed'" +literals = ["\"open\" enum open=Open case,closed=Closed", "'closed' enum open=Open case,closed=Closed"] + +[[test]] +expression = "status == \"open\" and" +literals = ["\"open\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "status == \"op" +literals = [] + +[[test]] +expression = "\"open\", \"closed\"" +subject = "status" +literals = ["\"open\" enum open=Open case,closed=Closed", "\"closed\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "age > 18 ? \"open\" : \"closed\"" +role = "value" +expected = "status" +literals = ["\"open\" enum open=Open case,closed=Closed", "\"closed\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "some([\"open\"], # in statuses)" +literals = ["\"open\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "active == true" +literals = ["true bool true"] + +[[test]] +expression = "since > d() and since < d().startOf(\"day\")" +literals = ["d() date now", "d().startOf(\"day\") date today"] + +[[test]] +expression = "since > d(\"2024-01-01\") and since < d(\"nope\")" +literals = ["d(\"2024-01-01\") date 2024-01-01", "d(\"nope\") date nope invalid"] + +[[test]] +expression = "since > d(\"2024-01-01\", \"Europe/Berlin\")" +literals = ["d(\"2024-01-01\", \"Europe/Berlin\") date 2024-01-01 tz=Europe/Berlin"] + +[[test]] +expression = "since > d(customer.since) and" +literals = ["d(customer.since) date field customer.since"] diff --git a/core/expression/tests/data/standard.csv b/core/expression/tests/data/standard.csv index 87e73e3d..8074835a 100644 --- a/core/expression/tests/data/standard.csv +++ b/core/expression/tests/data/standard.csv @@ -655,3 +655,8 @@ map(items, #.value * 2);{"items": [{"value": 1}, {"value": 2}]};[2, 4] # Backwards compatibility - # still works map([1, 2, 3], # * 2);;[2, 4, 6] filter([1, 2, 3, 4], # > 2);;[3, 4] + +# and binds tighter than or +true or false and false;;true +(true or false) and false;;false +some(items, # == 1 or # == 2 and # == 3);{ items: [1] };true diff --git a/core/expression/tests/data/unary.csv b/core/expression/tests/data/unary.csv index 3c0689ee..fe630dbd 100644 --- a/core/expression/tests/data/unary.csv +++ b/core/expression/tests/data/unary.csv @@ -107,19 +107,12 @@ bool('true'); { "$": true }; true bool('false'); { "$": false }; true bool(1); { "$": true }; true bool(0); { "$": false }; true +# Nested and/or follow precedence +some(items, # == 1 or # == 2 and # == 3);{ "$": 1, items: [1] };true +some(items, (# == 1 or # == 2) and # == 3);{ "$": 1, items: [1] };false +some(items, # == 2 and # == 3 or # == 1);{ "$": 1, items: [1] };true +(false or true) and false;{ "$": 1 };false +(true or false and false);{ "$": 1 };true -# Date-typed subject as an ISO string -> d('2024-01-01');{$: '2024-01-15'};true -< d('2024-01-01');{$: '2024-01-15'};false -== d('2024-01-15');{$: '2024-01-15'};true -[d('2024-01-01')..d('2024-12-31')];{$: '2024-06-01'};true -[d('2024-01-01')..d('2024-12-31')];{$: '2025-06-01'};false -(d('2024-01-01')..d('2024-12-31')];{$: '2024-01-01'};false -in [d('2024-01-01')..d('2024-12-31')];{$: '2024-12-31'};true -not in [d('2024-01-01')..d('2024-12-31')];{$: '2023-12-31'};true -in [d('2024-01-01'), d('2024-02-01')];{$: '2024-02-01'};true -in [d('2024-01-01'), d('2024-02-01')];{$: '2024-03-01'};false -not in [d('2024-01-01'), d('2024-02-01')];{$: '2024-03-01'};true -in [d('2024-01-01'), '2024-02-01'];{$: '2024-02-01'};true -in [d('2024-01-01'), 'not a date'];{$: '2024-01-01'};true -in ['2024-01-01', 'x'];{$: 'x'};true +# Top-level joiners combine left to right +> 1 or < 0 and > 5;{ "$": 3 };false diff --git a/core/expression/tests/helpers/slot_scope.rs b/core/expression/tests/helpers/slot_scope.rs deleted file mode 100644 index f707f6d7..00000000 --- a/core/expression/tests/helpers/slot_scope.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::rc::Rc; -use zen_expression::variable::VariableType; - -pub fn obj(fields: &[(&str, VariableType)]) -> VariableType { - let object = VariableType::empty_object(); - if let VariableType::Object(map) = &object { - for (key, value) in fields { - map.borrow_mut().insert(Rc::from(*key), value.clone()); - } - } - object -} - -fn enum_t(name: Option<&str>, values: &[&str]) -> VariableType { - VariableType::Enum( - name.map(Rc::from), - values.iter().map(|v| Rc::from(*v)).collect(), - ) -} - -pub fn status() -> VariableType { - enum_t(Some("status"), &["open", "closed"]) -} - -pub fn array(inner: VariableType) -> VariableType { - VariableType::Array(Rc::new(inner)) -} - -pub fn base_scope() -> VariableType { - obj(&[ - ("age", VariableType::Number), - ("name", VariableType::String), - ("active", VariableType::Bool), - ("since", VariableType::Date), - ( - "amount", - VariableType::Nullable(Rc::new(VariableType::Number)), - ), - ("status", status()), - ("tier", enum_t(None, &["gold", "silver"])), - ("mood", enum_t(Some("mood"), &["happy", "sad"])), - ( - "customer", - obj(&[ - ("age", VariableType::Number), - ("name", VariableType::String), - ("since", VariableType::Date), - ("status", status()), - ("address", obj(&[("city", VariableType::String)])), - ]), - ), - ( - "items", - array(obj(&[ - ("price", VariableType::Number), - ("status", status()), - ])), - ), - ("tags", array(VariableType::String)), - ("statuses", array(status())), - ("inbound", VariableType::Number), - ("andrew", VariableType::String), - ("nothing", VariableType::Bool), - ("true_value", VariableType::Number), - ("in_progress", VariableType::Bool), - ( - "orders", - array(obj(&[("sku", VariableType::String), ("status", status())])), - ), - ("grounding", obj(&[("status", status())])), - ]) -} - -pub fn scope_for(spec: &str) -> VariableType { - let subject = match spec { - "" => return base_scope(), - "$number" => VariableType::Number, - "$string" => VariableType::String, - "$date" => VariableType::Date, - "$bool" => VariableType::Bool, - "$any" => VariableType::Any, - "$status" => status(), - json => return serde_json::from_str(json).expect("scope json"), - }; - let scope = base_scope(); - if let VariableType::Object(map) = &scope { - map.borrow_mut().insert(Rc::from("$"), subject); - } - scope -} - -pub fn expected_for(spec: &str) -> Option { - Some(match spec { - "" => return None, - "bool" => VariableType::Bool, - "number" => VariableType::Number, - "string" => VariableType::String, - "date" => VariableType::Date, - "status" => status(), - json => serde_json::from_str(json).expect("expected json"), - }) -} diff --git a/core/expression/tests/isolate.rs b/core/expression/tests/isolate.rs index e61746f0..0412ca00 100644 --- a/core/expression/tests/isolate.rs +++ b/core/expression/tests/isolate.rs @@ -1046,52 +1046,3 @@ fn string_index_at_usize_max_returns_null() { let result = isolate.run_standard("s[i]").unwrap(); assert_eq!(result, Variable::Null); } - -#[test] -fn string_subject_against_date_bounds() { - let mut isolate = Isolate::new(); - isolate.set_environment(json!({ "$": "not a date", "since": "nope" }).into()); - - assert!(isolate - .run_unary("[d('2024-01-01')..d('2024-12-31')]") - .is_err()); - assert!(isolate - .run_standard("since in [d('2024-01-01')..d('2024-12-31')]") - .is_err()); - assert_eq!( - isolate - .run_standard("since in [d('2024-01-01'), d('2024-02-01')]") - .unwrap(), - Variable::Bool(false) - ); - assert!(isolate.run_standard("'x' in d('2024-01-01')").is_err()); -} - -#[test] -fn implicit_date_coercion_does_not_interpret_timezones_as_now() { - let mut isolate = Isolate::new(); - for source in [ - "'UTC' < d('2030-01-01')", - "d('2020-01-01') < 'Japan'", - "'Japan' in [d('2020-01-01')..d('2030-01-01')]", - ] { - assert!(isolate.run_standard(source).is_err(), "{source}"); - } - for source in ["'UTC' == d()", "'UTC' in [d()]"] { - assert_eq!( - isolate.run_standard(source).unwrap(), - Variable::Bool(false), - "{source}" - ); - } - assert_eq!( - isolate.run_standard("d('UTC').isValid()").unwrap(), - Variable::Bool(true) - ); - assert_eq!( - isolate - .run_standard("'2024-01-01' < d('2030-01-01')") - .unwrap(), - Variable::Bool(true) - ); -} diff --git a/core/expression/tests/literals.rs b/core/expression/tests/literals.rs deleted file mode 100644 index 662d01bb..00000000 --- a/core/expression/tests/literals.rs +++ /dev/null @@ -1,1123 +0,0 @@ -use std::rc::Rc; - -use zen_expression::intellisense::IntelliSense; -use zen_expression::slot::{DateArg, EnumTable, LabelResolver, LiteralFact}; -use zen_expression::variable::VariableType; - -#[path = "helpers/slot_scope.rs"] -mod slot_scope; - -use slot_scope::{expected_for, scope_for}; - -fn labels() -> LabelResolver { - Rc::new(|name: &str, value: &str| match (name, value) { - ("status", "open") => Some("Open case".to_string()), - ("status", "closed") => Some("Closed".to_string()), - _ => None, - }) -} - -#[test] -fn date_literal_validity_uses_the_explicit_zone() { - let (facts, _) = run("d(\"2024-09-08\", \"America/Santiago\")", false, "", ""); - assert!(facts[0].contains("invalid tz=America/Santiago")); - let mut is = IntelliSense::new(); - let (_, _, complete) = is.literal_analysis("status == \"open\"", false, &scope_for(""), None); - assert!(complete); - let (_, _, complete) = - is.literal_analysis("status == \"open\" and", false, &scope_for(""), None); - assert!(!complete); -} - -fn render(fact: &LiteralFact) -> String { - match fact { - LiteralFact::Enum { - span, - value, - name, - label, - valid, - enum_index, - } => format!( - "enum@{}..{} {value} {} #{enum_index} {label} {}", - span.0, - span.1, - if *valid { "valid" } else { "invalid" }, - name.as_deref().unwrap_or("-") - ), - LiteralFact::Date { span, arg } => { - let arg = match arg { - DateArg::Now => "now".to_string(), - DateArg::Today => "today".to_string(), - DateArg::Literal { value, valid, tz } => format!( - "literal {value} {}{}", - if *valid { "valid" } else { "invalid" }, - tz.as_ref() - .map(|tz| format!(" tz={tz}")) - .unwrap_or_default() - ), - DateArg::Field { path } => format!("field {path}"), - }; - format!("date@{}..{} {arg}", span.0, span.1) - } - LiteralFact::Bool { span, value } => format!("bool@{}..{} {value}", span.0, span.1), - } -} - -fn run(source: &str, unary: bool, scope: &str, expected: &str) -> (Vec, Vec) { - let mut is = IntelliSense::new(); - is.set_labels(Some(labels())); - let (facts, enums) = is.literals( - source, - unary, - &scope_for(scope), - expected_for(expected).as_ref(), - ); - (facts.iter().map(render).collect(), enums) -} - -struct Case { - source: &'static str, - unary: bool, - scope: &'static str, - expected: &'static str, - facts: &'static [&'static str], -} - -const CASES: &[Case] = &[ - Case { - source: "status == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "status == \"nope\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 nope invalid #0 nope status"], - }, - Case { - source: "\"open\" == status", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "status in [\"open\", \"closed\"]", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@11..17 open valid #0 Open case status", - "enum@19..27 closed valid #0 Closed status", - ], - }, - Case { - source: "status not in [\"open\", \"x\"]", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@15..21 open valid #0 Open case status", - "enum@23..26 x invalid #0 x status", - ], - }, - Case { - source: "tier == \"gold\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@8..14 gold valid #0 gold -"], - }, - Case { - source: "customer.status == \"closed\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@19..27 closed valid #0 Closed status"], - }, - Case { - source: "statuses[0] == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@15..21 open valid #0 Open case status"], - }, - Case { - source: "since > d()", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..11 now"], - }, - Case { - source: "since > d(\"2024-01-01\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..23 literal 2024-01-01 valid"], - }, - Case { - source: "since > d(\"not a date\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..23 literal not a date invalid"], - }, - Case { - source: "since > d(customer.since)", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..25 field customer.since"], - }, - Case { - source: "d()", - unary: false, - scope: "", - expected: "", - facts: &["date@0..3 now"], - }, - Case { - source: "d(\"2024-01-01\", \"Europe/Berlin\")", - unary: false, - scope: "", - expected: "", - facts: &["date@0..32 literal 2024-01-01 valid tz=Europe/Berlin"], - }, - Case { - source: "active == true", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@10..14 true"], - }, - Case { - source: "true", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@0..4 true"], - }, - Case { - source: "active and false", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@11..16 false"], - }, - Case { - source: "age > 1 ? true : false", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@10..14 true", "bool@17..22 false"], - }, - Case { - source: "\"open\", \"closed\"", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@0..6 open valid #0 Open case status", - "enum@8..16 closed valid #0 Closed status", - ], - }, - Case { - source: "\"open\"", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "[\"open\", \"x\"]", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@1..7 open valid #0 Open case status", - "enum@9..12 x invalid #0 x status", - ], - }, - Case { - source: "!= \"closed\"", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@3..11 closed valid #0 Closed status"], - }, - Case { - source: "\"open\" or \"closed\"", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@0..6 open valid #0 Open case status", - "enum@10..18 closed valid #0 Closed status", - ], - }, - Case { - source: "> d(\"2024-01-01\")", - unary: true, - scope: "$date", - expected: "", - facts: &["date@2..17 literal 2024-01-01 valid"], - }, - Case { - source: "[1..5]", - unary: true, - scope: "$number", - expected: "", - facts: &[], - }, - Case { - source: "true", - unary: true, - scope: "$bool", - expected: "", - facts: &["bool@0..4 true"], - }, - Case { - source: "since in [d(\"2024-01-01\")..d()]", - unary: false, - scope: "", - expected: "bool", - facts: &["date@10..25 literal 2024-01-01 valid", "date@27..30 now"], - }, - Case { - source: "some(items, #.status == \"open\")", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@24..30 open valid #0 Open case status"], - }, - Case { - source: "some([\"open\", \"x\"], # in statuses)", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@6..12 open valid #0 Open case status", - "enum@14..17 x invalid #0 x status", - ], - }, - Case { - source: "contains(statuses, \"open\")", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@19..25 open valid #0 Open case status"], - }, - Case { - source: "since.isAfter(d(\"2024-01-01\"))", - unary: false, - scope: "", - expected: "bool", - facts: &["date@14..29 literal 2024-01-01 valid"], - }, - Case { - source: "age > 18 ? \"open\" : \"closed\"", - unary: false, - scope: "", - expected: "status", - facts: &[ - "enum@11..17 open valid #0 Open case status", - "enum@20..28 closed valid #0 Closed status", - ], - }, - Case { - source: "status ?? \"x\"", - unary: false, - scope: "", - expected: "status", - facts: &["enum@10..13 x invalid #0 x status"], - }, - Case { - source: "\"open\"", - unary: false, - scope: "", - expected: "status", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "status == \"op", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "something == \"hello\"", - unary: false, - scope: "{\"Object\":{\"something\":{\"Const\":\"hello\"}}}", - expected: "bool", - facts: &["enum@13..20 hello valid #0 hello -"], - }, - Case { - source: "something == \"helloo\"", - unary: false, - scope: "{\"Object\":{\"something\":{\"Const\":\"hello\"}}}", - expected: "bool", - facts: &["enum@13..21 helloo invalid #0 helloo -"], - }, - Case { - source: "\"hello\" == something", - unary: false, - scope: "{\"Object\":{\"something\":{\"Const\":\"hello\"}}}", - expected: "bool", - facts: &["enum@0..7 hello valid #0 hello -"], - }, - Case { - source: "something in [\"hello\", \"x\"]", - unary: false, - scope: "{\"Object\":{\"something\":{\"Nullable\":{\"Const\":\"hello\"}}}}", - expected: "bool", - facts: &[ - "enum@14..21 hello valid #0 hello -", - "enum@23..26 x invalid #0 x -", - ], - }, - Case { - source: "something == \"b\"", - unary: false, - scope: "{\"Object\":{\"something\":{\"Enum\":[null,[\"a\",\"b\"]]}}}", - expected: "bool", - facts: &["enum@13..16 b valid #0 b -"], - }, - Case { - source: "\"open\" == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "`hello ${status}`", - unary: false, - scope: "", - expected: "", - facts: &[], - }, - Case { - source: "customer.name == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "name == \"Ünïcode\" and status == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@34..40 open valid #0 Open case status"], - }, - Case { - source: "since > d(\"2024-01-01T10:00:00Z\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..33 literal 2024-01-01T10:00:00Z valid"], - }, - Case { - source: "since > d(\"2024-01-01 10:00\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..29 literal 2024-01-01 10:00 valid"], - }, - Case { - source: "since > d(\"2024-13-01\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..23 literal 2024-13-01 invalid"], - }, - Case { - source: "since > d(\"2024-02-30\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..23 literal 2024-02-30 invalid"], - }, - Case { - source: "since > d(\"today\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..18 literal today invalid"], - }, - Case { - source: "since > d(\"\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..13 literal invalid"], - }, - Case { - source: "since > d(\"Europe/Berlin\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..26 literal Europe/Berlin valid"], - }, - Case { - source: "since > d(1700000000)", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "d(\"2026-09-19 10:00:00\", \"Europe/Berlin\")", - unary: false, - scope: "", - expected: "", - facts: &["date@0..41 literal 2026-09-19 10:00:00 valid tz=Europe/Berlin"], - }, - Case { - source: "since > d(\"2026-09-19 10:00:00\", \"Europe/Berlin\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..49 literal 2026-09-19 10:00:00 valid tz=Europe/Berlin"], - }, - Case { - source: "> d('2026-09-19 10:00:00', 'America/New_York')", - unary: true, - scope: "", - expected: "", - facts: &["date@2..46 literal 2026-09-19 10:00:00 valid tz=America/New_York"], - }, - Case { - source: "since > d(\"2026-09-19 10:00:00\", \"Mars/Olympus\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..48 literal 2026-09-19 10:00:00 invalid tz=Mars/Olympus"], - }, - Case { - source: "since > d(\"not a date\", \"Europe/Berlin\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..40 literal not a date invalid tz=Europe/Berlin"], - }, - Case { - source: "since > d(\"2026-09-19 10:00:00\", customer.zone)", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "d(\"2024-01-01\") < since", - unary: false, - scope: "", - expected: "bool", - facts: &["date@0..15 literal 2024-01-01 valid"], - }, - Case { - source: "d().startOf(\"day\")", - unary: false, - scope: "", - expected: "", - facts: &["date@0..18 today"], - }, - Case { - source: "since > d().startOf(\"day\")", - unary: false, - scope: "", - expected: "bool", - facts: &["date@8..26 today"], - }, - Case { - source: "> d().startOf('day')", - unary: true, - scope: "$date", - expected: "", - facts: &["date@2..20 today"], - }, - Case { - source: "d().startOf(\"month\") < since", - unary: false, - scope: "", - expected: "bool", - facts: &["date@0..3 now"], - }, - Case { - source: "d().add(1, \"day\") > since", - unary: false, - scope: "", - expected: "bool", - facts: &["date@0..3 now"], - }, - Case { - source: "since.add(1, \"day\").isAfter(d())", - unary: false, - scope: "", - expected: "bool", - facts: &["date@28..31 now"], - }, - Case { - source: "since.diff(d(\"2024-01-01\"), \"days\") > 1", - unary: false, - scope: "", - expected: "bool", - facts: &["date@11..26 literal 2024-01-01 valid"], - }, - Case { - source: "since in (d(\"2024-01-01\")..d(\"2024-12-31\"))", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "date@10..25 literal 2024-01-01 valid", - "date@27..42 literal 2024-12-31 valid", - ], - }, - Case { - source: "d(customer.since).isAfter(d(\"2024-01-01\"))", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "date@0..17 field customer.since", - "date@26..41 literal 2024-01-01 valid", - ], - }, - Case { - source: "d(items[0].price) > since", - unary: false, - scope: "", - expected: "bool", - facts: &["date@0..17 field items[0].price"], - }, - Case { - source: "> d($)", - unary: true, - scope: "$date", - expected: "", - facts: &["date@2..6 field $"], - }, - Case { - source: "> d($).startOf(\"day\")", - unary: true, - scope: "$date", - expected: "", - facts: &["date@2..6 field $"], - }, - Case { - source: "(status ?? \"open\") == \"closed\"", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@11..17 open valid #0 Open case status", - "enum@22..30 closed valid #0 Closed status", - ], - }, - Case { - source: "status ?? \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "age > 18 ? \"open\" : active ? \"closed\" : \"x\"", - unary: false, - scope: "", - expected: "status", - facts: &[ - "enum@11..17 open valid #0 Open case status", - "enum@29..37 closed valid #0 Closed status", - "enum@40..43 x invalid #0 x status", - ], - }, - Case { - source: "count(items, #.status in [\"open\", \"x\"]) > 0", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@26..32 open valid #0 Open case status", - "enum@34..37 x invalid #0 x status", - ], - }, - Case { - source: "filter(items, #.status == \"closed\")", - unary: false, - scope: "", - expected: "", - facts: &["enum@26..34 closed valid #0 Closed status"], - }, - Case { - source: "map(items, #.status == \"open\")", - unary: false, - scope: "", - expected: "", - facts: &["enum@23..29 open valid #0 Open case status"], - }, - Case { - source: "some(items, #.status == \"open\" or #.status == \"x\")", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@24..30 open valid #0 Open case status", - "enum@46..49 x invalid #0 x status", - ], - }, - Case { - source: "[\"open\", \"x\"]", - unary: false, - scope: "", - expected: "{\"Array\":{\"Enum\":[\"status\",[\"open\",\"closed\"]]}}", - facts: &[ - "enum@1..7 open valid #0 Open case status", - "enum@9..12 x invalid #0 x status", - ], - }, - Case { - source: "\"open\" in statuses", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "tier in [\"gold\", \"bronze\"]", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@9..15 gold valid #0 gold -", - "enum@17..25 bronze invalid #0 bronze -", - ], - }, - Case { - source: "items[0].status == \"open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@19..25 open valid #0 Open case status"], - }, - Case { - source: "customer.status in [\"open\"] and mood == \"happy\" and status == \"closed\"", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@20..26 open valid #0 Open case status", - "enum@40..47 happy valid #1 happy mood", - "enum@62..70 closed valid #0 Closed status", - ], - }, - Case { - source: "status == \"open\" ? true : false", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@10..16 open valid #0 Open case status", - "bool@19..23 true", - "bool@26..31 false", - ], - }, - Case { - source: "contains(name, \"open\")", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "`${status == \"open\"}`", - unary: false, - scope: "", - expected: "", - facts: &["enum@13..19 open valid #0 Open case status"], - }, - Case { - source: "{ status: \"open\" }", - unary: false, - scope: "", - expected: "{\"Object\":{\"status\":{\"Enum\":[\"status\",[\"open\",\"closed\"]]}}}", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "status == 'open'", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "status == \"Open\"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 Open invalid #0 Open status"], - }, - Case { - source: "active == false", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@10..15 false"], - }, - Case { - source: "true == active", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@0..4 true"], - }, - Case { - source: "not active", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "age == true", - unary: false, - scope: "", - expected: "bool", - facts: &[], - }, - Case { - source: "[true, false]", - unary: false, - scope: "", - expected: "{\"Array\":\"Bool\"}", - facts: &["bool@1..5 true", "bool@7..12 false"], - }, - Case { - source: "== true", - unary: true, - scope: "$bool", - expected: "", - facts: &["bool@3..7 true"], - }, - Case { - source: "in [\"open\", \"x\"]", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@4..10 open valid #0 Open case status", - "enum@12..15 x invalid #0 x status", - ], - }, - Case { - source: "not in [\"closed\"]", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@8..16 closed valid #0 Closed status"], - }, - Case { - source: "$ == \"open\"", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@5..11 open valid #0 Open case status"], - }, - Case { - source: "== \"x\"", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@3..6 x invalid #0 x status"], - }, - Case { - source: "\"open\" or \"x\"", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@0..6 open valid #0 Open case status", - "enum@10..13 x invalid #0 x status", - ], - }, - Case { - source: "\"open\", \"clo", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "\"open\"", - unary: true, - scope: "$string", - expected: "", - facts: &[], - }, - Case { - source: "d(\"2024-01-01\") a", - unary: true, - scope: "$date", - expected: "", - facts: &["date@0..15 literal 2024-01-01 valid"], - }, - Case { - source: "d(\"\") a", - unary: true, - scope: "$date", - expected: "", - facts: &["date@0..5 literal invalid"], - }, - Case { - source: "d() and", - unary: true, - scope: "$date", - expected: "", - facts: &["date@0..3 now"], - }, - Case { - source: "\"open\", \"closed\" a", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@0..6 open valid #0 Open case status", - "enum@8..16 closed valid #0 Closed status", - ], - }, - Case { - source: "\"open\" or", - unary: true, - scope: "$status", - expected: "", - facts: &["enum@0..6 open valid #0 Open case status"], - }, - Case { - source: "status in [\"open\", \"closed\"] ?", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@11..17 open valid #0 Open case status", - "enum@19..27 closed valid #0 Closed status", - ], - }, - Case { - source: "status == \"open\" and (", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "status == \"open\" ? \"closed\" : \"", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@10..16 open valid #0 Open case status"], - }, - Case { - source: "status == \"open\" ? \"closed\" : \"", - unary: false, - scope: "", - expected: "status", - facts: &[ - "enum@10..16 open valid #0 Open case status", - "enum@19..27 closed valid #0 Closed status", - ], - }, - Case { - source: "tier in [\"gold\", \"silver\"] and customer.age >", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@9..15 gold valid #0 gold -", - "enum@17..25 silver valid #0 silver -", - ], - }, - Case { - source: "[\"open\", \"closed\", ", - unary: true, - scope: "$status", - expected: "", - facts: &[ - "enum@1..7 open valid #0 Open case status", - "enum@9..17 closed valid #0 Closed status", - ], - }, - Case { - source: "[\"open\", \"closed\", ", - unary: false, - scope: "", - expected: "{\"Array\":{\"Enum\":[\"status\",[\"open\",\"closed\"]]}}", - facts: &[ - "enum@1..7 open valid #0 Open case status", - "enum@9..17 closed valid #0 Closed status", - ], - }, - Case { - source: "(status == \"open\" and (", - unary: false, - scope: "", - expected: "bool", - facts: &["enum@11..17 open valid #0 Open case status"], - }, - Case { - source: "active == true and (", - unary: false, - scope: "", - expected: "bool", - facts: &["bool@10..14 true"], - }, - Case { - source: "since.add(1, \"day\") > d() and (", - unary: false, - scope: "", - expected: "bool", - facts: &["date@22..25 now"], - }, - Case { - source: "status == \"open\" and since > d(\"2024-01-01\") and mood == \"", - unary: false, - scope: "", - expected: "bool", - facts: &[ - "enum@10..16 open valid #0 Open case status", - "date@29..44 literal 2024-01-01 valid", - ], - }, -]; - -#[test] -fn literal_facts() { - let mut failures = Vec::new(); - for case in CASES { - let (facts, _) = run(case.source, case.unary, case.scope, case.expected); - let wanted: Vec = case.facts.iter().map(|f| f.to_string()).collect(); - if facts != wanted { - failures.push(format!( - "{}\n wanted {:?}\n got {:?}", - case.source, wanted, facts - )); - } - } - assert!(CASES.len() >= 25); - assert!( - failures.is_empty(), - "{} of {} cases failed:\n{}", - failures.len(), - CASES.len(), - failures.join("\n") - ); -} - -#[test] -fn incomplete_tails_keep_earlier_facts() { - let mut failures = Vec::new(); - for case in CASES { - let (complete, _) = run(case.source, case.unary, case.scope, case.expected); - if complete.is_empty() || case.source.ends_with('"') && !case.source.ends_with("\"\"") { - continue; - } - let tails: &[&str] = if case.unary { - &[" a", " and", ", ", " or ("] - } else { - &[" and (", " ?", " and", " or (", " and customer.age >"] - }; - for tail in tails { - let source = format!("{}{tail}", case.source); - let (facts, _) = run(&source, case.unary, case.scope, case.expected); - if facts != complete { - failures.push(format!( - "{source}\n wanted {complete:?}\n got {facts:?}" - )); - } - } - } - assert!( - failures.is_empty(), - "{} tails failed:\n{}", - failures.len(), - failures.join("\n") - ); -} - -#[test] -fn enum_tables_are_deduplicated() { - let (facts, enums) = run( - "status == \"open\" or mood == \"happy\" or status == \"closed\"", - false, - "", - "bool", - ); - assert_eq!(facts.len(), 3); - assert_eq!(enums.len(), 2); - assert_eq!(enums[0].name.as_deref(), Some("status")); - assert_eq!(enums[0].options[0].label, "Open case"); - assert_eq!(enums[0].options[0].source.as_deref(), Some("\"open\"")); - assert_eq!(enums[1].name.as_deref(), Some("mood")); - assert_eq!(enums[1].options[0].label, "happy"); - assert!(facts[1].contains("#1")); - assert!(facts[2].contains("#0")); -} - -#[test] -fn anonymous_enum_table_has_no_name() { - let (_, enums) = run("tier == \"gold\"", false, "", "bool"); - assert_eq!(enums.len(), 1); - assert_eq!(enums[0].name, None); - assert_eq!( - enums[0] - .options - .iter() - .map(|o| o.value.as_str()) - .collect::>(), - vec!["gold", "silver"] - ); -} - -#[test] -fn const_literal_has_anonymous_table() { - let scope = "{\"Object\":{\"something\":{\"Const\":\"hello\"}}}"; - let (facts, enums) = run("something == \"hello\"", false, scope, "bool"); - assert_eq!( - facts, - vec!["enum@13..20 hello valid #0 hello -".to_string()] - ); - assert_eq!(enums.len(), 1); - assert_eq!(enums[0].name, None); - assert_eq!(enums[0].options.len(), 1); - assert_eq!(enums[0].options[0].value, "hello"); - assert_eq!(enums[0].options[0].label, "hello"); - assert_eq!(enums[0].options[0].source.as_deref(), Some("\"hello\"")); - - let (facts, enums) = run("something == \"helloo\"", false, scope, "bool"); - assert_eq!( - facts, - vec!["enum@13..21 helloo invalid #0 helloo -".to_string()] - ); - assert_eq!(enums.len(), 1); - assert_eq!(enums[0].options[0].value, "hello"); -} - -#[test] -fn facts_without_labels_fall_back_to_values() { - let mut is = IntelliSense::new(); - let (facts, enums) = is.literals( - "status == \"open\"", - false, - &scope_for(""), - Some(&VariableType::Bool), - ); - assert_eq!(render(&facts[0]), "enum@10..16 open valid #0 open status"); - assert_eq!(enums[0].options[1].label, "closed"); -} diff --git a/core/expression/tests/slots.rs b/core/expression/tests/slots.rs index 36626b64..c1309b39 100644 --- a/core/expression/tests/slots.rs +++ b/core/expression/tests/slots.rs @@ -1,10 +1,216 @@ -use std::ops::Index; use std::rc::Rc; +use serde::Deserialize; use zen_expression::intellisense::IntelliSense; -use zen_expression::slot::{LabelResolver, SlotRole, SlotState, ValueOption}; +use zen_expression::slot::{DateArg, LabelResolver, LiteralFact, Literals, Slot, SlotRole}; use zen_expression::variable::VariableType; +const BASE_SCOPE: &str = r#"{"Object":{ + "age":"Number","name":"String","active":"Bool","since":"Date", + "amount":{"Nullable":"Number"}, + "status":{"Enum":["status",["open","closed"]]}, + "tier":{"Enum":[null,["gold","silver"]]}, + "mood":{"Enum":["mood",["happy","sad"]]}, + "grade":{"Enum":["grade",["a","b","c","d"]]}, + "customer":{"Object":{"age":"Number","name":"String","since":"Date", + "status":{"Enum":["status",["open","closed"]]}, + "address":{"Object":{"city":"String"}}}}, + "items":{"Array":{"Object":{"price":"Number","status":{"Enum":["status",["open","closed"]]}}}}, + "tags":{"Array":"String"}, + "statuses":{"Array":{"Enum":["status",["open","closed"]]}}, + "grounding":{"Object":{"status":{"Enum":["status",["open","closed"]]}}} +}}"#; + +#[derive(Deserialize)] +struct TestFile { + test: Vec, +} + +#[derive(Deserialize)] +struct TestCase { + expression: String, + role: Option, + subject: Option, + scope: Option, + expected: Option, + slot: Option, + literals: Option>, +} + +#[derive(Deserialize)] +struct ExpectedSlot { + state: String, + #[serde(rename = "type")] + kind: Option, + options: Option>, + operators: Option>, + span: Option, + auto_open: Option, +} + +impl TestCase { + fn role(&self) -> SlotRole { + match self.role.as_deref() { + None if self.subject.is_some() => SlotRole::Unary, + None | Some("condition") => SlotRole::Condition, + Some("value") => SlotRole::Value, + Some("path") => SlotRole::Path, + Some(other) => panic!("unknown role {other}"), + } + } + + fn scope(&self) -> VariableType { + let scope = parse_type(self.scope.as_deref().unwrap_or(BASE_SCOPE)); + if let (Some(subject), VariableType::Object(map)) = (&self.subject, &scope) { + map.borrow_mut().insert(Rc::from("$"), parse_type(subject)); + } + scope + } + + fn expected(&self) -> Option { + match (&self.expected, self.role()) { + (Some(spec), _) => Some(parse_type(spec)), + (None, SlotRole::Condition) => Some(VariableType::Bool), + _ => None, + } + } + + fn check(&self, is: &mut IntelliSense) -> Vec { + let (source, pos) = match self.expression.find('|') { + Some(caret) => (self.expression.replacen('|', "", 1), caret), + None => (self.expression.clone(), self.expression.len()), + }; + let result = is.slot( + &source, + pos as u32, + self.subject.is_some(), + self.role(), + &self.scope(), + self.expected().as_ref(), + ); + let mut failures = Vec::new(); + if let Some(expected) = &self.slot { + failures.extend(expected.check(&result.slot)); + } + if let Some(expected) = &self.literals { + let actual = render_literals(&source, &result.literals); + if &actual != expected { + failures.push(format!("literals: wanted {expected:?}, got {actual:?}")); + } + } + failures + } +} + +impl ExpectedSlot { + fn check(&self, slot: &Slot) -> Vec { + let state = serde_json::to_value(slot.state).unwrap(); + let options: Vec = slot + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + let operators: Vec = slot.operators.iter().map(|o| o.to_string()).collect(); + let fields = [ + ( + "state", + Some(self.state.clone()), + state.as_str().unwrap().to_string(), + ), + ( + "type", + self.kind.clone(), + slot.expected + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_default(), + ), + ( + "options", + self.options.as_ref().map(|o| o.join(",")), + options.join(","), + ), + ( + "operators", + self.operators.as_ref().map(|o| o.join(",")), + operators.join(","), + ), + ( + "span", + self.span.clone(), + format!("{}..{}", slot.replace_span.0, slot.replace_span.1), + ), + ( + "auto_open", + self.auto_open.map(|a| a.to_string()), + slot.auto_open.to_string(), + ), + ]; + fields + .into_iter() + .filter_map(|(field, wanted, actual)| { + let wanted = wanted?; + (wanted != actual).then(|| format!("{field}: wanted {wanted:?}, got {actual:?}")) + }) + .collect() + } +} + +fn parse_type(spec: &str) -> VariableType { + let json = match spec { + "bool" => r#""Bool""#, + "number" => r#""Number""#, + "string" => r#""String""#, + "date" => r#""Date""#, + "any" => r#""Any""#, + "status" => r#"{"Enum":["status",["open","closed"]]}"#, + "grade" => r#"{"Enum":["grade",["a","b","c","d"]]}"#, + other => other, + }; + serde_json::from_str(json).unwrap_or_else(|e| panic!("type {spec}: {e}")) +} + +fn render_literals(source: &str, literals: &Literals) -> Vec { + literals + .facts + .iter() + .map(|fact| { + let span = fact.span(); + let text = &source[span.0 as usize..span.1 as usize]; + match fact { + LiteralFact::Enum { + valid, enum_index, .. + } => { + let options: Vec = literals.enums[*enum_index as usize] + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + let invalid = if *valid { "" } else { " invalid" }; + format!("{text} enum {}{invalid}", options.join(",")) + } + LiteralFact::Date { arg, .. } => match arg { + DateArg::Now => format!("{text} date now"), + DateArg::Today => format!("{text} date today"), + DateArg::Field { path } => format!("{text} date field {path}"), + DateArg::Literal { value, valid, tz } => { + let invalid = if *valid { "" } else { " invalid" }; + let tz = tz.as_ref().map(|z| format!(" tz={z}")).unwrap_or_default(); + format!("{text} date {value}{tz}{invalid}") + } + }, + LiteralFact::Bool { value, .. } => format!("{text} bool {value}"), + } + }) + .collect() +} + fn labels() -> LabelResolver { Rc::new(|name: &str, value: &str| { let label = match (name, value) { @@ -18,488 +224,19 @@ fn labels() -> LabelResolver { }) } -#[path = "helpers/slot_scope.rs"] -mod slot_scope; -use slot_scope::{array, base_scope, expected_for, obj, scope_for, status}; - -fn role_for(spec: &str) -> SlotRole { - match spec { - "unary" => SlotRole::Unary, - "condition" => SlotRole::Condition, - "value" => SlotRole::Value, - "path" => SlotRole::Path, - other => panic!("unknown role {other}"), - } -} - -fn state_name(state: SlotState) -> &'static str { - match state { - SlotState::Start => "start", - SlotState::UnaryStart => "unaryStart", - SlotState::Value => "value", - SlotState::ListElement => "listElement", - SlotState::Range => "range", - SlotState::Operator => "operator", - SlotState::Logical => "logical", - SlotState::Argument => "argument", - SlotState::Member => "member", - SlotState::Closure => "closure", - SlotState::InString => "inString", - SlotState::Path => "path", - } -} - -fn options_tag(options: &[ValueOption]) -> String { - options +#[test] +fn slots() { + let file: TestFile = toml::from_str(include_str!("data/slots.toml")).expect("slots.toml"); + let mut is = IntelliSense::new(); + is.set_labels(Some(labels())); + let failures: Vec = file + .test .iter() - .map(|o| { - if o.label == o.value { - o.value.clone() - } else { - format!("{}={}", o.value, o.label) - } + .flat_map(|test| { + test.check(&mut is) + .into_iter() + .map(move |f| format!("{}\n {f}", test.expression)) }) - .collect::>() - .join(",") -} - -#[test] -fn slots_csv() { - let csv_data = include_str!("data/slots.csv"); - let mut reader = csv::ReaderBuilder::new() - .delimiter(b';') - .quoting(false) - .flexible(true) - .from_reader(csv_data.as_bytes()); - - let mut is = IntelliSense::new(); - is.set_labels(Some(labels())); - let mut failures = Vec::new(); - let mut count = 0; - - for record in reader.records() { - let row = record.expect("csv row"); - if row.len() < 11 || row.index(0).starts_with('#') || row.index(0).is_empty() { - continue; - } - count += 1; - let role = role_for(row.index(0)); - let unary = row.index(1) == "true"; - let scope = scope_for(row.index(2)); - let expected = expected_for(row.index(3)); - let text = row.index(4); - let caret = text.find('|').expect("caret"); - let source = format!("{}{}", &text[..caret], &text[caret + 1..]); - - let result = is.slot( - &source, - caret as u32, - unary, - role, - &scope, - expected.as_ref(), - ); - let slot = result.slot; - - let actual = [ - state_name(slot.state).to_string(), - slot.expected - .as_ref() - .map(|t| t.to_string()) - .unwrap_or_default(), - options_tag(&slot.options), - slot.operators.join(","), - format!("{}..{}", slot.replace_span.0, slot.replace_span.1), - slot.auto_open.to_string(), - ]; - let wanted = [ - row.index(5).to_string(), - row.index(6).to_string(), - row.index(7).to_string(), - row.index(8).to_string(), - row.index(9).to_string(), - row.index(10).to_string(), - ]; - if actual != wanted { - failures.push(format!( - "{text}\n wanted {}\n got {}", - wanted.join(" ; "), - actual.join(" ; ") - )); - } - assert_eq!(result.unary, unary); - } - - assert!(count >= 80, "only {count} rows"); - assert!( - failures.is_empty(), - "{} of {count} rows failed:\n{}", - failures.len(), - failures.join("\n") - ); -} - -fn slot_at( - text: &str, - unary: bool, - role: SlotRole, - scope: &str, - expected: &str, -) -> zen_expression::slot::Slot { - let mut is = IntelliSense::new(); - is.set_labels(Some(labels())); - let caret = text.find('|').expect("caret"); - let source = format!("{}{}", &text[..caret], &text[caret + 1..]); - is.slot( - &source, - caret as u32, - unary, - role, - &scope_for(scope), - expected_for(expected).as_ref(), - ) - .slot -} - -#[test] -fn review_cursor_context_regressions() { - for text in ["map(items as |)", "map(items as x|)"] { - let slot = slot_at(text, false, SlotRole::Condition, "", ""); - assert!(!slot.auto_open, "{text}"); - assert!(slot.operators.is_empty(), "{text}"); - } - let glued = slot_at("status ??|", false, SlotRole::Value, "", "status"); - assert_eq!(glued.state, SlotState::Value); - assert_eq!(glued.replace_span, (9, 9)); - assert!(slot_at("age not i|", false, SlotRole::Condition, "", "") - .operators - .contains(&"not in")); - assert_eq!( - slot_at(" cust|", false, SlotRole::Path, "", "").state, - SlotState::Path - ); - assert!(slot_at("d(customer.|)", false, SlotRole::Condition, "", "") - .wanted_scalar() - .is_none()); - for text in ["len(customer.|)", "age in [customer.|]"] { - assert!( - !slot_at(text, false, SlotRole::Condition, "", "").can_chain, - "{text}" - ); - } -} - -#[test] -fn nested_lists_keep_element_expectations() { - let expected = array(status()); - for text in [ - "[|]", - "([|])", - "active ? [|] : []", - "statuses ?? [|]", - "{statuses: [|]}", - ] { - let wanted = if text.starts_with('{') { - obj(&[("statuses", expected.clone())]) - } else { - expected.clone() - }; - let caret = text.find('|').unwrap(); - let result = IntelliSense::new().slot( - &text.replace('|', ""), - caret as u32, - false, - SlotRole::Value, - &base_scope(), - Some(&wanted), - ); - assert_eq!(result.slot.expected, Some(status()), "{text}"); - assert_eq!(result.slot.options.len(), 2, "{text}"); - } -} - -#[test] -fn enum_options_insert_valid_container_shapes() { - for text in ["status in |", "status not in |"] { - let slot = slot_at(text, false, SlotRole::Condition, "", ""); - for option in slot.options { - let source = text.replace('|', option.source.as_ref().unwrap()); - assert!( - zen_expression::evaluate_expression( - &source, - serde_json::json!({"status": "open"}).into() - ) - .is_ok(), - "{source}" - ); - } - } - let expected = array(array(status())); - let result = IntelliSense::new().slot( - "", - 0, - false, - SlotRole::Value, - &base_scope(), - Some(&expected), - ); - assert_eq!( - result.slot.options[0].source.as_deref(), - Some("[[\"open\"]]") - ); -} - -#[test] -fn string_prefix_and_duplicate_unary_values_follow_the_caret() { - let slot = slot_at("status == \"op|n\"", false, SlotRole::Condition, "", ""); - assert_eq!( - slot.options - .iter() - .map(|o| o.value.as_str()) - .collect::>(), - vec!["open"] - ); - assert_eq!(slot.replace_span, (10, 15)); - let slot = slot_at("\"open\", \"open|", true, SlotRole::Unary, "$status", ""); - assert!(slot.options.is_empty()); - let scope = base_scope(); - scope.dot_insert("$", VariableType::Nullable(Rc::new(status()))); - let result = IntelliSense::new().slot("null, ", 6, true, SlotRole::Unary, &scope, None); - assert!(!result - .slot - .options - .iter() - .any(|o| o.source.as_deref() == Some("null"))); -} - -#[test] -fn argument_carries_function_and_index() { - let slot = slot_at("d(|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.function.as_deref(), Some("d")); - assert_eq!(slot.argument, Some(0)); - assert_eq!(slot.operand, None); - - let slot = slot_at("contains(name, |", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.function.as_deref(), Some("contains")); - assert_eq!(slot.argument, Some(1)); - - let slot = slot_at("since.isAfter(|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.function.as_deref(), Some("isAfter")); - assert_eq!(slot.argument, Some(0)); - assert_eq!(slot.operand, Some(VariableType::Date)); -} - -#[test] -fn closure_carries_element_type() { - let slot = slot_at("some(items, |", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.state, SlotState::Closure); - assert_eq!(slot.function.as_deref(), Some("some")); - assert_eq!(slot.argument, Some(1)); - assert!(matches!(slot.operand, Some(VariableType::Object(_)))); - - let slot = slot_at("some(items, #.|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.state, SlotState::Member); - assert!(matches!(slot.operand, Some(VariableType::Object(_)))); -} - -#[test] -fn listed_values_are_removed() { - let slot = slot_at("\"open\", |", true, SlotRole::Unary, "$status", ""); - assert_eq!(slot.listed, vec!["open".to_string()]); - assert_eq!(slot.options.len(), 1); - assert_eq!(slot.options[0].value, "closed"); - assert_eq!(slot.options[0].source.as_deref(), Some("\"closed\"")); - - let slot = slot_at( - "status in [\"closed\", |", - false, - SlotRole::Condition, - "", - "bool", - ); - assert_eq!(slot.listed, vec!["closed".to_string()]); - assert_eq!(slot.options[0].value, "open"); - assert_eq!(slot.options[0].label, "Open case"); - - let slot = slot_at("[\"open\"], \"|", true, SlotRole::Unary, "$status", ""); - assert_eq!(slot.state, SlotState::InString); - assert_eq!(slot.listed, vec!["open".to_string()]); -} - -#[test] -fn in_string_reports_quote_and_operand() { - let slot = slot_at("status == '|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.in_string, Some('\'')); - assert_eq!(slot.operand, Some(status())); - assert!(zen_expression::evaluate_expression( - "status == '", - serde_json::json!({ "status": "open" }).into(), - ) - .is_err()); - - let slot = slot_at( - "status == \"open\"|", - false, - SlotRole::Condition, - "", - "bool", - ); - assert_eq!(slot.in_string, None); - assert_eq!(slot.state, SlotState::Logical); - - let slot = slot_at("`a ${|}`", false, SlotRole::Value, "", "string"); - assert_eq!(slot.state, SlotState::Start); - assert_eq!(slot.in_string, None); -} - -#[test] -fn operand_types_survive_unclosed_parens() { - let slot = slot_at("(customer.age |", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.state, SlotState::Operator); - assert_eq!(slot.operand, Some(VariableType::Number)); - - let slot = slot_at( - "(status == \"open\" or status == |", - false, - SlotRole::Condition, - "", - "bool", - ); - assert_eq!(slot.operand, Some(status())); -} - -#[test] -fn unicode_byte_spans() { - let text = "name == \"Ünïcode\" and status == \"|"; - let slot = slot_at(text, false, SlotRole::Condition, "", "bool"); - let caret = text.find('|').unwrap() as u32; - assert_eq!(slot.replace_span, (caret - 1, caret)); - assert_eq!(slot.options.len(), 2); - - let slot = slot_at("mood == \"😀|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.replace_span, (8, 13)); - assert_eq!(slot.options[0].label, "😀 Happy"); - - let source = "mood == \"😀 Happy\""; - let mut is = IntelliSense::new(); - let inside = is.slot( - source, - 11, - false, - SlotRole::Condition, - &base_scope(), - Some(&VariableType::Bool), - ); - assert_eq!(inside.slot.state, SlotState::InString); - assert_eq!(inside.slot.replace_span, (8, source.len() as u32)); -} - -#[test] -fn slot_includes_literal_facts() { - let mut is = IntelliSense::new(); - is.set_labels(Some(labels())); - let source = "status == \"open\" and since > d(\"2024-01-01\") and tier == "; - let result = is.slot( - source, - source.len() as u32, - false, - SlotRole::Condition, - &base_scope(), - Some(&VariableType::Bool), - ); - assert_eq!(result.slot.state, SlotState::Value); - assert_eq!(result.literals.len(), 2); - assert_eq!(result.enums.len(), 1); - assert_eq!(result.enums[0].name.as_deref(), Some("status")); - assert_eq!(result.enums[0].options[0].label, "Open case"); -} - -#[test] -fn assignments_and_semicolons() { - let slot = slot_at("age > 1;|", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.state, SlotState::Start); - assert_eq!(slot.expected, Some(VariableType::Bool)); - - let slot = slot_at("x = 1; |", false, SlotRole::Condition, "", "bool"); - assert_eq!(slot.state, SlotState::Start); - assert_eq!(slot.expected, Some(VariableType::Bool)); - - let slot = slot_at( - "x = 1; status == \"|", - false, - SlotRole::Condition, - "", - "bool", - ); - assert_eq!(slot.state, SlotState::InString); - assert_eq!(slot.options.len(), 2); - assert_eq!(slot.replace_span, (17, 18)); - - 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()); + .collect(); + assert!(failures.is_empty(), "{}", failures.join("\n")); } diff --git a/core/expression/tests/standard.rs b/core/expression/tests/standard.rs index 222eef11..d90f8279 100644 --- a/core/expression/tests/standard.rs +++ b/core/expression/tests/standard.rs @@ -321,26 +321,3 @@ fn failure_tests() { assert!(parser_result.error().is_err(), "{parser_result:?}"); } } - -#[test] -fn interval_span_is_bytes() { - let bump = Bump::new(); - let mut lexer = Lexer::new(); - let tokens = lexer.tokenize(&bump, "age in [10..25]").unwrap(); - let parser = Parser::try_new(&tokens, &bump) - .unwrap() - .standard() - .with_metadata(); - let result = parser.parse(); - let metadata = result.metadata.unwrap(); - - let Node::Binary { right, .. } = result.root else { - panic!("expected binary node"); - }; - assert!(matches!(right, Node::Interval { .. })); - let span = metadata - .get(&(*right as *const Node as usize)) - .map(|m| m.span) - .unwrap(); - assert_eq!(span, (7, 15)); -} diff --git a/core/expression/tests/unary.rs b/core/expression/tests/unary.rs index 5441068f..3d1123b7 100644 --- a/core/expression/tests/unary.rs +++ b/core/expression/tests/unary.rs @@ -172,44 +172,3 @@ 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}"); - } -} - -#[test] -fn nested_logical_precedence_matches_standard_evaluation() { - use serde_json::json; - use zen_expression::Isolate; - let mut isolate = Isolate::new(); - isolate.set_environment(json!({ "$": true }).into()); - for source in [ - "== (true or false and false)", - "== (false and false or true)", - "== contains([true], true or false and false)", - "== [true or false and false][0]", - "== (`${true or false and false}` == 'true')", - ] { - assert!(isolate.run_unary(source).unwrap(), "{source}"); - } -} diff --git a/core/expression/tests/variable_type_cycles.rs b/core/expression/tests/variable_type_cycles.rs deleted file mode 100644 index d413ef7a..00000000 --- a/core/expression/tests/variable_type_cycles.rs +++ /dev/null @@ -1,129 +0,0 @@ -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)); -} - -fn branching_cycle(leaf: VariableType) -> VariableType { - let nodes: Vec<_> = (0..3).map(|_| VariableType::empty_object()).collect(); - for (i, node) in nodes.iter().enumerate() { - node.dot_insert("left", nodes[(i + 1) % 3].shallow_clone()); - node.dot_insert("right", nodes[(i + 2) % 3].shallow_clone()); - node.dot_insert("leaf", leaf.shallow_clone()); - } - nodes[0].shallow_clone() -} - -#[test] -fn separately_allocated_branching_cycles_are_bounded_and_check_leaves() { - let a = branching_cycle(VariableType::Number); - let b = branching_cycle(VariableType::Number); - assert!(a.satisfies(&b)); - assert!(!a.satisfies(&branching_cycle(VariableType::Bool))); - let merged = a.merge(&b); - assert_eq!(merged.get("leaf"), VariableType::Number); - assert_eq!(merged.get("left").get("leaf"), VariableType::Number); - let mut nested = merged; - for _ in 0..12 { - nested = nested.get("left"); - assert_eq!(nested.get("leaf"), VariableType::Number); - } - assert!(format!("{a:?}").contains("Object()")); -} - -#[test] -fn debug_preserves_existing_acyclic_format() { - let value = VariableType::empty_object(); - value.dot_insert("age", VariableType::Number.array()); - assert_eq!( - format!("{value:?}"), - "Object(RefCell { value: {\"age\": Array(Number)} })" - ); - assert_eq!( - format!( - "{:?}", - VariableType::Nullable(Rc::new(VariableType::String)) - ), - "Nullable(String)" - ); -} diff --git a/core/types/src/variable/ser.rs b/core/types/src/variable/ser.rs index 8d79272b..e23e3dd1 100644 --- a/core/types/src/variable/ser.rs +++ b/core/types/src/variable/ser.rs @@ -26,8 +26,6 @@ impl Serialize for Variable { } #[cfg(not(feature = "arbitrary_precision"))] Variable::Number(v) => { - // to_u64/to_i64 TRUNCATE fractional decimals instead of - // returning None; take the integer path only for integers. if v.is_integer() { if let Some(u) = v.to_u64() { return serializer.serialize_u64(u); diff --git a/core/types/src/variable_type/mod.rs b/core/types/src/variable_type/mod.rs index 26a2660c..a3f13c3c 100644 --- a/core/types/src/variable_type/mod.rs +++ b/core/types/src/variable_type/mod.rs @@ -12,7 +12,6 @@ use std::rc::Rc; type RcCell = Rc>; -/// Object nesting kept when serialising; deeper (or cyclic) objects are emitted empty. pub const MAX_TYPE_DEPTH: usize = 32; #[derive(Clone, Eq, PartialEq, Deserialize)] diff --git a/core/types/src/variable_type/util.rs b/core/types/src/variable_type/util.rs index 3e4b0c76..90af41bc 100644 --- a/core/types/src/variable_type/util.rs +++ b/core/types/src/variable_type/util.rs @@ -209,8 +209,6 @@ impl VariableType { VariableType::Object(o1.clone()) } (VariableType::Object(o1), VariableType::Object(o2)) => { - // Include depth to preserve the existing cutoff and the fields available at - // each level. Repeated branches reuse work without truncating a cycle early. let key = ( Rc::as_ptr(o1) as *const (), Rc::as_ptr(o2) as *const (),