refactor: slot completion cleanup and date/precedence fixes

This commit is contained in:
Stefan
2026-09-23 14:58:47 +02:00
parent 42c90f8ca3
commit 29c2b1cfd5
63 changed files with 3325 additions and 7808 deletions
-2
View File
@@ -459,8 +459,6 @@ export interface DecisionNode {
config: any
}
export declare function encodeZenString(value: string): string | null
export interface EvaluateBatchRequest {
key: string
context: any
-1
View File
@@ -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
-2
View File
@@ -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<String>,
}
@@ -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 ()>,
-7
View File
@@ -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<SlotRequest>, strict: Option<bool>) -> napi::Res
Ok(out)
}
#[napi]
pub fn encode_zen_string(value: String) -> Option<String> {
zen_expression::slot::encode_string(&value)
}
fn parse_role(role: &str) -> napi::Result<SlotRole> {
match role {
"unary" => Ok(SlotRole::Unary),
-190
View File
@@ -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();
});
});
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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),
+1 -2
View File
@@ -221,8 +221,7 @@ impl DataModelIr {
visited: &mut HashSet<Arc<str>>,
) -> 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,
@@ -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<BlockRef>,
pub instance_source: Option<InstanceSource>,
/// Writer's document position plus one (0 for a free read): the tie-break between independent paths.
pub rank: usize,
}
@@ -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<PropertyNode, ()>) -> Option<Vec<NodeIndex>> {
type Ready = BinaryHeap<Reverse<(usize, usize)>>;
let policy_of = |idx: NodeIndex| {
@@ -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));
-1
View File
@@ -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,
}
-2
View File
@@ -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::<str>::from(policy)];
while let Some(path) = stack.pop() {
+6 -7
View File
@@ -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("")),
-30
View File
@@ -25,36 +25,6 @@ impl SchemaType {
Self::resolve::<true>(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<const DATE_HINTS: bool>(
schema: &Value,
dictionaries: &SchemaDictionaries,
+117 -123
View File
@@ -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<str>,
pub target: CursorTarget,
pub source: Arc<str>,
@@ -23,8 +20,8 @@ pub struct ExpressionFacts {
pub role: SlotRole,
pub subject_type: Option<VariableType>,
pub expected_type: Option<VariableType>,
pub literals: Vec<LiteralFact>,
pub enums: Vec<EnumTable>,
#[serde(flatten)]
pub literals: Literals,
pub subject_options: Vec<ValueOption>,
}
@@ -39,9 +36,9 @@ impl Db {
let path: Arc<str> = 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<str>) -> Vec<Site> {
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<str>) -> Vec<Site> {
let Some(policy) = self.raw_policy(path) else {
return Vec::new();
};
let block_id: Arc<str> = Arc::from(block_id);
let mut push = |target: CursorTarget, source: &Arc<str>| {
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<str> = Arc::from(block_id);
let mut push = |target: CursorTarget, source: &Arc<str>| {
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<str> = 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<str> = 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<str>) -> Vec<Site> {
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<str>) -> Vec<Site> {
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()
}
}
-212
View File
@@ -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<CursorScope> {
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<CursorScope> {
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<VariableType> {
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<str>, 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()
}
+77 -125
View File
@@ -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<VariableType>,
pub expected_type: Option<VariableType>,
pub slot: Slot,
pub literals: Vec<LiteralFact>,
pub enums: Vec<EnumTable>,
#[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<Arc<str>, HashMap<Arc<str>, Arc<str>>>;
enum LabelKey {
@@ -120,22 +79,24 @@ pub(crate) struct LabelCache {
entries: RefCell<HashMap<Arc<str>, LabelEntry>>,
}
fn label_map(
dictionaries: impl Iterator<Item = (Arc<str>, Arc<DictionaryIr>)>,
) -> Option<Arc<LabelMap>> {
let mut labels = LabelMap::new();
for (name, dict) in dictionaries {
let entries: HashMap<Arc<str>, Arc<str>> = 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<Item = (Arc<str>, Arc<DictionaryIr>)>,
) -> Option<Arc<LabelMap>> {
let mut labels = LabelMap::new();
for (name, dict) in dictionaries {
let entries: HashMap<Arc<str>, Arc<str>> = 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<VariableType>) -> 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<VariableType> {
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<VariableType> {
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<VariableType> {
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<VariableType> {
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<VariableType> {
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
}
}
-185
View File
@@ -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<CursorScope> {
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<CursorScope> {
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<CursorScope> {
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<CursorScope> {
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<CursorScope> {
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<str>, 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<VariableType> {
if path.is_empty() {
return None;
}
let kind = known_type(db.snapshot().base_scope.resolve_at(path))?;
let segments: Vec<Rc<str>> = 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<VariableType> {
if path.is_empty() {
return None;
}
declared_type(db, unit, path).or_else(|| literal_union(scope.resolve_at(path)))
}
+313
View File
@@ -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<CursorScope> {
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<CursorScope> {
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<VariableType> {
if path.is_empty() {
return None;
}
let kind = CursorScope::known_type(self.snapshot().base_scope.resolve_at(path))?;
let segments: Vec<Rc<str>> = 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<VariableType> {
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<CursorScope> {
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<CursorScope> {
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<VariableType> {
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<str>,
scope: &VariableType,
) -> VariableType {
IntelliSenseSource::analyze(
&mut is.borrow_mut(),
source,
ExpressionKind::Standard,
scope,
)
.return_type
.shallow_clone()
}
}
+5 -48
View File
@@ -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<str>, Arc<str>, Arc<str>);
#[derive(Default)]
pub(super) struct SiblingCache {
columns: HashMap<(Arc<str>, Arc<str>, Arc<str>), ColumnTypes>,
columns: HashMap<ColumnKey, ColumnTypes>,
}
struct ColumnTypes {
@@ -25,7 +27,6 @@ fn merge(a: Option<VariableType>, b: Option<&VariableType>) -> Option<VariableTy
}
impl SiblingCache {
/// The cache belongs to one query, so source/scope changes cannot reuse stale types.
pub(super) fn infer(
&mut self,
cursor: &Cursor,
@@ -58,50 +59,6 @@ impl SiblingCache {
.get(row)
.unwrap_or(&column.total)
.clone()
.and_then(literal_union)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_column_is_analyzed_once_and_excludes_only_the_active_row() {
let mut cache = SiblingCache::default();
let mut calls = 0;
for (row, expected) in [
("a", vec!["b", "c"]),
("b", vec!["a", "c"]),
("c", vec!["a", "b"]),
("new", vec!["a", "b", "c"]),
] {
let cursor = Cursor {
policy_path: "p".into(),
block_id: "t".into(),
pos: 0,
target: CursorTarget::DecisionTableCell {
row: row.into(),
col: "out".into(),
},
};
let inferred = cache
.infer(&cursor, || {
calls += 1;
["a", "b", "c"]
.into_iter()
.map(|s| (Arc::from(s), Some(VariableType::Const(s.into()))))
.collect()
})
.unwrap();
let VariableType::Enum(_, values) = inferred else {
panic!("expected enum");
};
assert_eq!(
values.iter().map(|v| v.as_ref()).collect::<Vec<_>>(),
expected
);
}
assert_eq!(calls, 1);
.and_then(CursorScope::literal_union)
}
}
-52
View File
@@ -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);
}
}
+28 -6
View File
@@ -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")]
@@ -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\""
}
]
}
}
}
]
}
@@ -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
}
]
}
@@ -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"
}
]
}
}
}
]
}
+383
View File
@@ -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"
-2
View File
@@ -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)]
-87
View File
@@ -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());
}
}
-80
View File
@@ -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<String> = ws
.completions(&Cursor {
policy_path: Arc::from("p"),
block_id: Arc::from("partial"),
pos: 20,
target: CursorTarget::Expression { id: Arc::from("x") },
})
.into_iter()
.map(|c| c.label)
.collect();
for expected in ["customer", "$root", "len"] {
assert!(
labels.iter().any(|l| l == expected),
"caret after `or ` should offer scope completions: {labels:?}"
);
}
}
-242
View File
@@ -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)
);
}
-268
View File
@@ -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>) -> 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::<zen_engine::model::DecisionContent>(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::<zen_engine::model::DecisionContent>(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());
}
File diff suppressed because it is too large Load Diff
+1 -128
View File
@@ -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"], "==");
}
+129 -1
View File
@@ -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<String>,
#[serde(default)]
graphs: Vec<String>,
test: Vec<SlotsCase>,
}
#[derive(Debug, Deserialize)]
struct SlotsCase {
name: String,
policy: String,
block_id: String,
target: String,
id: Option<String>,
row: Option<String>,
kind: Option<String>,
role: Option<String>,
expected: Option<String>,
subject: Option<String>,
text: Option<String>,
state: Option<String>,
options: Option<Vec<String>>,
}
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<VariableType>| 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<String> = 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<String>,
@@ -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"));
}
-149
View File
@@ -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:?}"
);
}
}
@@ -30,10 +30,8 @@ pub struct Completion {
pub boost: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub method_for: Option<VariableType>,
/// Type of the field or local a variable or property completion names.
#[serde(skip)]
pub var_type: Option<VariableType>,
/// Text an editor appends after accepting the item: `.` into an object, a space before an operator.
#[serde(skip_serializing_if = "Option::is_none")]
pub follow: Option<&'static str>,
}
@@ -47,7 +45,7 @@ impl Completions {
data: &VariableType,
slot: &crate::slot::Slot,
) -> Vec<Completion> {
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<str>, VariableType)]) -> Vec<Completion> {
let mut completions = Vec::new();
@@ -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<Vec<Rc<str>>> {
match t {
VariableType::Const(c) => Some(vec![c.clone()]),
-1
View File
@@ -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>>,
-1
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+407 -402
View File
@@ -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<VariableType>,
struct Entry<'a> {
node: &'a Node<'a>,
span: Span,
expected: Option<VariableType>,
}
pub(crate) struct NodeTable<'a> {
pub entries: Vec<Entry<'a>>,
entries: Vec<Entry<'a>>,
}
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<VariableType>)> = Vec::new();
let push = |children: &mut Vec<(&'a Node<'a>, Option<VariableType>)>,
n: &'a Node<'a>,
e: Option<VariableType>| 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<VariableType> {
match (node, parsed.type_of(node)) {
(Node::String(_), VariableType::Const(_)) => Some(VariableType::String),
(_, t) => declared(t),
}
}
pub(crate) fn declared(t: VariableType) -> Option<VariableType> {
match t {
VariableType::Any => None,
other => Some(other),
}
}
pub(crate) fn item_expectation(t: Option<&VariableType>) -> Option<VariableType> {
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<VariableType> {
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<Option<VariableType>> {
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<VariableType> {
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<Vec<String>> {
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<Option<VariableType>> {
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<VariableType> {
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<EnumTable>,
struct EnumInterner<'l> {
tables: Vec<EnumTable>,
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<str>>, values: &[Rc<str>]) -> u32 {
fn intern(&mut self, name: Option<&Rc<str>>, values: &[Rc<str>]) -> 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<LiteralFact> {
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<LiteralFact> {
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<DateArg> {
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<TokenLiteral<'a>> {
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<Self> {
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<usize> {
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<usize> {
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<LiteralFact> {
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<DateArg> {
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<usize> {
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<usize> {
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<DateArg> {
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<LiteralFact>, Vec<EnumTable>) {
let mut interner = EnumInterner::new(labels);
let mut facts: Vec<LiteralFact> = 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<DateArg> {
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<Vec<String>> {
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<LiteralFact> = 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,
}
}
}
+182 -191
View File
@@ -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<dyn Fn(&str, &str) -> Option<String>>;
@@ -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<LiteralFact>,
#[serde(rename = "literals")]
pub facts: Vec<LiteralFact>,
pub enums: Vec<EnumTable>,
}
pub fn encode_string(value: &str) -> Option<String> {
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<str>],
labels: Option<&LabelResolver>,
) -> Vec<ValueOption> {
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<Vec<ValueOption>> {
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<Rc<str>>, Vec<Rc<str>>);
pub(crate) const NULL_SOURCE: &str = "null";
pub trait VariableTypeSlot {
fn innermost(&self) -> &VariableType;
fn enum_domain(&self) -> Option<EnumDomain>;
fn declared(self) -> Option<VariableType>;
}
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<EnumDomain> {
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<VariableType> {
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<EnumDomain> {
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<Vec<Self>> {
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<str>],
labels: Option<&LabelResolver>,
) -> Vec<Self> {
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<String> {
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<VariableType>) -> 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<Parsed<'a>> {
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<Self> {
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<str>, 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<LiteralFact>, Vec<EnumTable>) {
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<LiteralFact>, Vec<EnumTable>, 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<ScalarClass> {
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<Self> {
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),
}
}
}
+59 -62
View File
@@ -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()
}
}
-8
View File
@@ -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);
}
+3 -8
View File
@@ -64,12 +64,7 @@ impl VmDate {
/// Create a new VmDate from the current time
pub fn new(var: Variable, tz_opt: Option<Tz>) -> 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<Tz>) -> 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<Tz>, allow_zone: bool) -> Option<DateTime<Tz>> {
pub fn parse_date(var: Variable, tz_opt: Option<Tz>) -> Option<DateTime<Tz>> {
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(),
+2 -58
View File
@@ -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::<VmInterval>();
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(),
});
})
}
};
-97
View File
@@ -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<String> = is
.completions("", 0, &data)
.into_iter()
.map(|c| c.label)
.collect();
let second: Vec<String> = is
.completions("", 0, &data)
.into_iter()
.map(|c| c.label)
.collect();
assert_eq!(first, second);
assert_eq!(
&first[..6],
["Alpha", "beta", "gamma", "zeta", "$", "$root"]
);
assert_eq!(
first[6], "len",
"functions keep registry order after the variables"
);
let props: Vec<String> = is
.completions("gamma.", 6, &data)
.into_iter()
.filter(|c| c.kind == zen_expression::intellisense::completion::CompletionKind::Property)
.map(|c| c.label)
.collect();
assert_eq!(props, ["a", "X", "y"]);
}
#[test]
fn completions_offer_closure_locals_first() {
let mut is = IntelliSense::new();
let data: VariableType = serde_json::from_str(
r#"{"Object":{"i":{"Array":"String"},"m":{"Array":{"Object":{"a":"Number","tags":{"Array":"String"}}}}}}"#,
)
.unwrap();
let mut labels = |source: &str| -> Vec<(String, String)> {
is.completions(source, source.len() as u32, &data)
.into_iter()
.map(|c| (c.label, c.detail))
.collect()
};
let got = labels("map(m as x, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("x", "object"));
assert_eq!(got[1].0, "i");
assert_eq!(got[2].0, "m");
let got = labels("map(m as x, x");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["x"]
);
let got = labels("map(m, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("#", "object"));
let got = labels("map(m, #");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["#"]
);
let got = labels("map(m as x, map(x.tags as y, ");
assert_eq!((got[0].0.as_str(), got[0].1.as_str()), ("y", "string"));
assert_eq!(got[1].0, "x");
assert_eq!(got[2].0, "i");
let got = labels("map(m as x, map(x.tags as y, y");
assert_eq!(
got.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
["y"]
);
let got = labels("filter(m as x, x.a > ");
assert_eq!(got[0].0, "x");
let got = labels("map(m as x, x.a) + ");
assert_eq!(got[0].0, "i");
assert!(got.iter().all(|(l, _)| l != "x"));
let boost = IntelliSense::new()
.completions("map(m as x, ", 12, &data)
.into_iter()
.find(|c| c.label == "x")
.and_then(|c| c.boost);
assert_eq!(boost, Some(30));
}
+1 -21
View File
@@ -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
1 expression (string);input (json 5);output (json 5)
244
245
246
247
-702
View File
@@ -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
Can't render this file because it contains an unexpected character in line 20 and column 44.
+509
View File
@@ -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"]
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 14 and column 13.
+8 -15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 8.
-102
View File
@@ -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<VariableType> {
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"),
})
}
-49
View File
@@ -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)
);
}
File diff suppressed because it is too large Load Diff
+221 -484
View File
@@ -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<TestCase>,
}
#[derive(Deserialize)]
struct TestCase {
expression: String,
role: Option<String>,
subject: Option<String>,
scope: Option<String>,
expected: Option<String>,
slot: Option<ExpectedSlot>,
literals: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct ExpectedSlot {
state: String,
#[serde(rename = "type")]
kind: Option<String>,
options: Option<Vec<String>>,
operators: Option<Vec<String>>,
span: Option<String>,
auto_open: Option<bool>,
}
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<VariableType> {
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<String> {
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<String> {
let state = serde_json::to_value(slot.state).unwrap();
let options: Vec<String> = slot
.options
.iter()
.map(|o| match o.label == o.value {
true => o.value.clone(),
false => format!("{}={}", o.value, o.label),
})
.collect();
let operators: Vec<String> = 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<String> {
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<String> = 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<String> = 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::<Vec<_>>()
.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<_>>(),
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<String> {
slot.locals.iter().map(|l| l.name.clone()).collect()
};
let element = |slot: &zen_expression::slot::Slot, i: usize| slot.locals[i].kind.to_string();
let slot = slot_at("map(items as x, |", false, SlotRole::Value, "", "");
assert_eq!(slot.state, SlotState::Closure);
assert_eq!(names(&slot), ["x"]);
assert!(matches!(slot.locals[0].kind, VariableType::Object(_)));
assert!(matches!(slot.operand, Some(VariableType::Object(_))));
let slot = slot_at("map(items as x, x|", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["x"]);
assert_eq!(slot.replace_span, (16, 17));
let nested =
r#"{"Object":{"m":{"Array":{"Object":{"a":"Number","tags":{"Array":"String"}}}}}}"#;
let slot = slot_at(
"map(m as x, map(x.tags as y, |",
false,
SlotRole::Value,
nested,
"",
);
assert_eq!(names(&slot), ["y", "x"]);
assert_eq!(element(&slot, 0), "string");
assert!(matches!(slot.locals[1].kind, VariableType::Object(_)));
let slot = slot_at("map(m, map(#.tags, |", false, SlotRole::Value, nested, "");
assert_eq!(names(&slot), ["#"]);
assert_eq!(element(&slot, 0), "string");
let slot = slot_at("map(items, |", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["#"]);
assert!(matches!(slot.locals[0].kind, VariableType::Object(_)));
let slot = slot_at(
"filter(items as x, x.price > |",
false,
SlotRole::Condition,
"",
"bool",
);
assert_eq!(slot.state, SlotState::Value);
assert_eq!(names(&slot), ["x"]);
let slot = slot_at("map(items as x, len(x|", false, SlotRole::Value, "", "");
assert_eq!(names(&slot), ["x"]);
assert!(slot_at(
"map(items as x, x.price) + |",
false,
SlotRole::Value,
"",
""
)
.locals
.is_empty());
assert!(slot_at("map(|", false, SlotRole::Value, "", "")
.locals
.is_empty());
.collect();
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
-23
View File
@@ -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));
}
-41
View File
@@ -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}");
}
}
@@ -1,129 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use zen_expression::variable::{VariableMapExt, VariableType};
type Fields = ahash::HashMap<Rc<str>, VariableType>;
fn cyclic_pair() -> (VariableType, VariableType) {
let claim = VariableType::Object(Rc::new(RefCell::new(Fields::new())));
let address = VariableType::Object(Rc::new(RefCell::new(Fields::new())));
let VariableType::Object(claim_fields) = &claim else {
unreachable!()
};
let VariableType::Object(address_fields) = &address else {
unreachable!()
};
claim_fields
.borrow_mut()
.insert(Rc::from("amount"), VariableType::Number);
claim_fields
.borrow_mut()
.insert(Rc::from("address"), address.shallow_clone());
claim_fields
.borrow_mut()
.insert(Rc::from("items"), address.shallow_clone().array());
address_fields
.borrow_mut()
.insert(Rc::from("city"), VariableType::String);
address_fields
.borrow_mut()
.insert(Rc::from("claim"), claim.shallow_clone());
(claim, address)
}
#[test]
fn serialize_cuts_cycles_and_keeps_the_derived_format() {
let (claim, _) = cyclic_pair();
let json = serde_json::to_value(&claim).expect("cyclic type serialises");
assert_eq!(json["Object"]["amount"], "Number");
assert_eq!(json["Object"]["address"]["Object"]["city"], "String");
assert_eq!(
json["Object"]["address"]["Object"]["claim"],
serde_json::json!({ "Object": {} })
);
assert_eq!(
json["Object"]["items"]["Array"]["Object"]["claim"],
serde_json::json!({ "Object": {} })
);
}
#[test]
fn serialize_round_trips_acyclic_types() {
let mut fields = Fields::new();
fields.insert(
Rc::from("a"),
VariableType::Nullable(Rc::new(VariableType::Enum(
Some(Rc::from("status")),
vec![Rc::from("open"), Rc::from("closed")],
)))
.array(),
);
fields.insert(Rc::from("c"), VariableType::Const(Rc::from("v")));
fields.insert(Rc::from("d"), VariableType::Date);
let original = VariableType::Object(Rc::new(RefCell::new(fields)));
let json = serde_json::to_string(&original).expect("serialise");
let back: VariableType = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, original);
}
#[test]
fn merge_and_satisfies_terminate_on_cyclic_objects() {
let (claim, address) = cyclic_pair();
let merged = claim.merge(&claim);
assert!(matches!(merged, VariableType::Object(_)));
assert!(claim.satisfies(&claim));
assert!(claim.get("address").satisfies(&address));
let (other_claim, _) = cyclic_pair();
let merged = claim.merge(&other_claim);
assert!(matches!(merged, VariableType::Object(_)));
assert!(claim.satisfies(&other_claim));
assert!(claim
.merge(&VariableType::Number)
.satisfies(&VariableType::Any));
}
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(<recursive>)"));
}
#[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)"
);
}
-2
View File
@@ -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);
-1
View File
@@ -12,7 +12,6 @@ use std::rc::Rc;
type RcCell<T> = Rc<RefCell<T>>;
/// Object nesting kept when serialising; deeper (or cyclic) objects are emitted empty.
pub const MAX_TYPE_DEPTH: usize = 32;
#[derive(Clone, Eq, PartialEq, Deserialize)]
-2
View File
@@ -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 (),