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