feat: policy dictionary (#479)

* feat: policy dictionary

* remove enum references

* improve tests

* reset index.js and package.json
This commit is contained in:
stefan-gorules
2026-07-08 13:21:03 +02:00
committed by GitHub
parent d3cd7ccad9
commit b4b59eb7c5
23 changed files with 2112 additions and 1145 deletions
+3 -3
View File
@@ -15,8 +15,8 @@ prepareLocalPublish:
yarn createNpmDirs
publish:
cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts
cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts
npm publish --registry $(REGISTRY) --ignore-scripts
cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
publishLocal: prepareLocalPublish wasm darwinArm publish
+4
View File
@@ -193,6 +193,8 @@ export interface NlResult {
diagnostics: NlDiagnostic[];
/** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */
subjectType?: PolicyVariableType;
/** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */
subjectOptions?: NlEnumOption[];
}
/**
@@ -212,6 +214,8 @@ export interface PolicyNlExpression {
diagnostics: NlDiagnostic[];
/** Resolved `$` type for unary cells. */
subjectType?: PolicyVariableType;
/** Labeled options when the unary subject is an enum (dictionary labels applied). */
subjectOptions?: NlEnumOption[];
}
/**
+17 -65
View File
@@ -193,6 +193,8 @@ export interface NlResult {
diagnostics: NlDiagnostic[];
/** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */
subjectType?: PolicyVariableType;
/** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */
subjectOptions?: NlEnumOption[];
}
/**
@@ -212,6 +214,8 @@ export interface PolicyNlExpression {
diagnostics: NlDiagnostic[];
/** Resolved `$` type for unary cells. */
subjectType?: PolicyVariableType;
/** Labeled options when the unary subject is an enum (dictionary labels applied). */
subjectOptions?: NlEnumOption[];
}
/**
@@ -362,30 +366,14 @@ export declare class PolicyWorkspace {
constructor()
setPolicy(path: string, document: any): void
removePolicy(path: string): boolean
/**
* Upsert a single block in an existing policy (replace-by-id or append).
* Errors when the policy does not exist — call `setPolicy` first to
* create one.
*/
updateBlock(req: PolicyUpdateBlockRequest): void
/**
* Remove a block from an existing policy by id. Returns `true` when a
* block was removed, `false` when the policy or block didn't exist.
*/
removeBlock(req: PolicyRemoveBlockRequest): boolean
policyPaths(): Array<string>
/**
* `max_diagnostics` caps the returned list (default: 100). Pass `0` for
* no cap.
*/
diagnostics(policyPath: string, maxDiagnostics?: number | undefined | null): Array<PolicyDiagnostic>
/**
* `max_diagnostics` caps the returned list (default: 100). Pass `0` for
* no cap.
*/
allDiagnostics(maxDiagnostics?: number | undefined | null): Array<PolicyDiagnostic>
entities(req: PolicyScopeRequest): Array<PolicyEntityInfo>
globals(req: PolicyScopeRequest): Array<PolicyGlobalInfo>
dictionaries(req: PolicyScopeRequest): Array<PolicyDictionaryInfo>
inputs(req: PolicyScopeRequest): Array<PolicyInputProperty>
outputs(req: PolicyScopeRequest): Array<PolicyOutputProperty>
conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema
@@ -394,32 +382,9 @@ export declare class PolicyWorkspace {
nlTokenize(cursor: PolicyExpressionCursor, text: string): NlResult | null
completions(cursor: PolicyExpressionCursor): Array<PolicyCompletion>
prepareRename(cursor: PolicyExpressionCursor): PolicyPrepareRenameResult | null
/**
* Returns block-level edits the host applies via id-keyed swap (same
* path as `update_block`). Each edit's `kind` field discriminates the
* variant; `replaceBlock` carries a `newBlock` payload that is the
* rewritten wire-format `BlockDoc`.
*/
rename(req: PolicyRenameRequest): PolicyEngineEdit[]
/**
* Returns every site in the workspace where `target` is used. Same
* visitor as `rename`; carries policy/block/expression/source/span/kind
* for each site so hosts can render a "find references" panel or drive
* navigation.
*/
references(target: any): PolicyReferenceSite[]
/**
* Default-valued JSON object that matches the workspace's input shape
* for `req.policy_path` (and optionally `req.goals`). Hosts use it as
* the initial value of a "Run simulation" panel so `evaluate` can be
* called immediately without first authoring an input by hand.
*/
inputSkeleton(req: PolicyScopeRequest): unknown
/**
* Returns the transitive dependency tree rooted at `target`. Inverse
* of `references()`. Per-write granularity — multi-output blocks
* don't conflate sibling outputs' deps.
*/
dependencies(target: string): PolicyDependencyNode
evaluate(req: PolicyEvaluateRequest): PolicyEvaluationResult
enhanceTrace(req: PolicyEvaluateRequest): PolicyEvaluationResult
@@ -446,7 +411,7 @@ export declare class ZenEngine {
getDecision(key: string): Promise<ZenDecision>
safeEvaluate(key: string, context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<{ success: true, data: ZenEngineResponse } | { success: false; error: any; }>
safeGetDecision(key: string): Promise<{ success: true, data: ZenDecision } | { success: false; error: any; }>
evaluateBatch(requests: Array<EvaluateBatchRequest>, opts?: ZenEvaluateOptions | undefined | null): Promise<Array<EvaluateBatchResult>>
evaluateBatch(requests: Array<EvaluateBatchRequest>, opts?: ZenEvaluateOptions | undefined | null): Promise<Array<{ success: true; data: ZenEngineResponse } | { success: false; error: any }>>
reload(): Promise<void>
compileFailures(): Array<{ key: string; kind: string; diagnostics?: Array<{ code: string; message: string; severity: string }>; error?: string }>
dispose(): void
@@ -472,12 +437,6 @@ export interface EvaluateBatchRequest {
context: any
}
export interface EvaluateBatchResult {
success: boolean
data?: any
error?: any
}
export declare function evaluateExpression(expression: string, context?: any | undefined | null): Promise<any>
export declare function evaluateExpressionSync(expression: string, context?: any | undefined | null): any
@@ -524,6 +483,17 @@ export interface PolicyDiagnostic {
target?: PolicyCursorTarget
}
export interface PolicyDictionaryEntryInfo {
value: string
label: string
}
export interface PolicyDictionaryInfo {
name: string
source: string
entries: Array<PolicyDictionaryEntryInfo>
}
export interface PolicyDiscriminantVariant {
value?: string
arm: string
@@ -550,7 +520,6 @@ export interface PolicyEntityInfo {
export interface PolicyEvaluateRequest {
policyPath: string
input: unknown
/** Goals to evaluate. Omit or pass empty for full evaluation. */
goals?: Array<string>
trace?: boolean
}
@@ -559,18 +528,9 @@ export interface PolicyExpressionCursor {
policyPath: string
blockId: string
pos: number
/**
* Tagged `{ kind, ...payload }` discriminating what kind of span the
* cursor sits in. See `PolicyCursorTarget` in the TypeScript types.
*/
target: PolicyCursorTarget
}
/**
* Kept as a `#[napi(object)]` struct purely so NAPI-RS emits the TS type
* used by `PolicyFieldOrigin.schema.fieldKind`. The runtime shape is
* hand-built in [`field_kind_to_json`].
*/
export interface PolicyFieldKindInfo {
kind: PolicyFieldKind
target?: string
@@ -640,19 +600,11 @@ export interface PolicySchemaGroup {
export interface PolicyScopeRequest {
policyPath: string
/**
* Goals to constrain schema introspection to. Omit or pass empty
* for everything reachable from the policy.
*/
goals?: Array<string>
}
export interface PolicyUpdateBlockRequest {
policyPath: string
/**
* A single wire block (same shape as one entry of `PolicyDocument.blocks`).
* Upserted by `block.id`: replaces in place if present, appends otherwise.
*/
block: unknown
}
+2
View File
@@ -581,6 +581,8 @@ module.exports.evaluateExpression = nativeBinding.evaluateExpression
module.exports.evaluateExpressionSync = nativeBinding.evaluateExpressionSync
module.exports.evaluateUnaryExpression = nativeBinding.evaluateUnaryExpression
module.exports.evaluateUnaryExpressionSync = nativeBinding.evaluateUnaryExpressionSync
module.exports.nlEncodeString = nativeBinding.nlEncodeString
module.exports.nlTokenizeBatch = nativeBinding.nlTokenizeBatch
module.exports.overrideConfig = nativeBinding.overrideConfig
module.exports.renderTemplate = nativeBinding.renderTemplate
module.exports.renderTemplateSync = nativeBinding.renderTemplateSync
+33
View File
@@ -103,6 +103,19 @@ pub struct PolicyGlobalInfo {
pub origin: Value,
}
#[napi(object)]
pub struct PolicyDictionaryInfo {
pub name: String,
pub source: String,
pub entries: Vec<PolicyDictionaryEntryInfo>,
}
#[napi(object)]
pub struct PolicyDictionaryEntryInfo {
pub value: String,
pub label: String,
}
#[napi(object)]
pub struct PolicyInputProperty {
pub path: String,
@@ -540,6 +553,26 @@ impl PolicyWorkspace {
.collect()
}
#[napi]
pub fn dictionaries(&self, req: PolicyScopeRequest) -> Vec<PolicyDictionaryInfo> {
self.inner
.dictionaries(&req.into())
.into_iter()
.map(|d| PolicyDictionaryInfo {
name: d.name.to_string(),
source: d.source.to_string(),
entries: d
.entries
.iter()
.map(|e| PolicyDictionaryEntryInfo {
value: e.value.to_string(),
label: e.label.to_string(),
})
.collect(),
})
.collect()
}
#[napi]
pub fn inputs(&self, req: PolicyScopeRequest) -> Vec<PolicyInputProperty> {
self.inner
+33 -1
View File
@@ -12,7 +12,9 @@ use crate::policy::blocks::{
SharedIntelliSense,
};
use crate::policy::evaluator::EvalArtifact;
use crate::policy::ir::{DataModelIr, ParsedPolicy, Policy, Property, PropertyPath, Scope};
use crate::policy::ir::{
DataModelIr, DictionaryIr, ParsedPolicy, Policy, Property, PropertyPath, Scope,
};
use crate::policy::queries::dependency::{
DataModelPaths, DependencyGraph, EnrichedState, EvalGraph, RuleShallowAnalysis, ShallowAnalyses,
};
@@ -141,6 +143,14 @@ pub struct Unit {
opcode_cache: OnceCell<Arc<OpcodeCache>>,
pub data_models: Vec<DataModelEntry>,
pub entities: HashMap<Arc<str>, Arc<DataModelIr>>,
pub dictionaries: HashMap<Arc<str>, Arc<DictionaryIr>>,
pub dictionary_blocks: Vec<DictionaryUnitEntry>,
}
pub struct DictionaryUnitEntry {
pub policy_path: Arc<str>,
pub block_id: Arc<str>,
pub ir: Arc<DictionaryIr>,
}
pub struct Db {
@@ -624,6 +634,26 @@ impl Snapshot {
});
let entities = Self::compute_unit_entities(&subset);
let dictionaries = Self::compute_dictionary_map(&subset);
let mut dictionary_blocks: Vec<DictionaryUnitEntry> = subset
.iter()
.flat_map(|(path, p)| {
p.policy
.dictionaries
.iter()
.map(move |block| DictionaryUnitEntry {
policy_path: path.clone(),
block_id: block.id.clone(),
ir: block.ir.clone(),
})
})
.collect();
dictionary_blocks.sort_by(|a, b| {
a.ir.name
.cmp(&b.ir.name)
.then_with(|| a.policy_path.cmp(&b.policy_path))
});
Unit {
members: member_set,
@@ -639,6 +669,8 @@ impl Snapshot {
opcode_cache: OnceCell::new(),
data_models,
entities,
dictionaries,
dictionary_blocks,
}
}
+39 -5
View File
@@ -44,29 +44,63 @@ impl Db {
return Vec::new();
};
let scope = self.enriched(policy).scope.shallow_clone();
let labels = self.nl_label_resolver(policy);
let intellisense = self.intellisense();
let mut is = intellisense.borrow_mut();
is.set_nl_labels(labels);
let mut out = Vec::new();
for rule in parsed.policy.rules() {
out.extend(rule.nl(&policy_arc, &scope, &mut is));
}
is.set_nl_labels(None);
out
}
pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option<NlResult> {
let (kind, scope) = self.nl_scope(cursor)?;
let unary = matches!(kind, ExpressionKind::Unary);
let labels = self.nl_label_resolver(&cursor.policy_path);
let intellisense = self.intellisense();
let mut result =
intellisense
.borrow_mut()
.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope);
let mut is = intellisense.borrow_mut();
is.set_nl_labels(labels);
let mut result = is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope);
if unary {
result.subject_type = Some(scope.get("$"));
let subject = scope.get("$");
result.subject_options = is.nl_subject_options(&subject);
result.subject_type = Some(subject);
}
is.set_nl_labels(None);
Some(result)
}
fn nl_label_resolver(
&self,
policy: &str,
) -> Option<zen_expression::intellisense::NlLabelResolver> {
let unit = self.unit(policy);
if unit.dictionary_blocks.is_empty() {
return None;
}
let mut labels: HashMap<Arc<str>, HashMap<Arc<str>, Arc<str>>> = HashMap::new();
for (name, dict) in &unit.dictionaries {
let entries: HashMap<Arc<str>, Arc<str>> = dict
.entries
.iter()
.filter(|e| !e.label.is_empty())
.map(|e| (e.value.clone(), e.label.clone()))
.collect();
if !entries.is_empty() {
labels.insert(name.clone(), entries);
}
}
if labels.is_empty() {
return None;
}
Some(std::rc::Rc::new(move |name: &str, value: &str| {
labels.get(name)?.get(value).map(|l| l.to_string())
}))
}
fn nl_scope(&self, cursor: &Cursor) -> Option<(ExpressionKind, VariableType)> {
let block = self.block_ir(&BlockRef {
policy_path: cursor.policy_path.clone(),
+112 -4
View File
@@ -6,7 +6,9 @@ use ahash::{HashMap, HashMapExt, HashSet, HashSetExt};
use zen_expression::variable::VariableType;
use crate::policy::blocks::{AssertionIr, Block, DecisionTableIr, ExpressionIr, MatchIr};
use crate::policy::raw::{BlockDoc, DataModelDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc};
use crate::policy::raw::{
BlockDoc, DataModelDoc, DictionaryDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc,
};
use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, SchemaFieldKind};
use crate::policy::ArcStrTrim;
@@ -16,6 +18,7 @@ pub type PropertyPath = Arc<str>;
pub struct Policy {
pub rules: Vec<Block>,
pub data_models: Vec<DataModelBlock>,
pub dictionaries: Vec<DictionaryBlock>,
pub imports: Vec<Arc<str>>,
}
@@ -25,6 +28,12 @@ pub struct DataModelBlock {
pub ir: Arc<DataModelIr>,
}
#[derive(Debug, Clone)]
pub struct DictionaryBlock {
pub id: Arc<str>,
pub ir: Arc<DictionaryIr>,
}
#[derive(Debug, Clone)]
pub struct ParsedPolicy {
pub policy: Arc<Policy>,
@@ -36,6 +45,7 @@ impl Policy {
let mut diagnostics = Vec::new();
let mut rules = Vec::new();
let mut data_models = Vec::new();
let mut dictionaries = Vec::new();
for env in &doc.blocks {
match env {
@@ -59,6 +69,14 @@ impl Policy {
});
}
}
BlockDoc::Dictionary { id, data } => {
if let Some(ir) = DictionaryIr::parse(id, data, path, &mut diagnostics) {
dictionaries.push(DictionaryBlock {
id: id.clone(),
ir: Arc::new(ir),
});
}
}
BlockDoc::Ignored(_) => {}
}
}
@@ -74,6 +92,7 @@ impl Policy {
policy: Arc::new(Policy {
rules,
data_models,
dictionaries,
imports,
}),
diagnostics: Arc::new(diagnostics),
@@ -96,6 +115,10 @@ impl Policy {
self.data_models().filter(|(_, dm)| dm.scope.is_global())
}
pub fn dictionaries(&self) -> impl Iterator<Item = (&Arc<str>, &Arc<DictionaryIr>)> {
self.dictionaries.iter().map(|b| (&b.id, &b.ir))
}
pub fn imports(&self) -> &[Arc<str>] {
&self.imports
}
@@ -194,6 +217,7 @@ impl DataModelIr {
pub(crate) fn wire_property_type(
prop: &Property,
entities: &HashMap<Arc<str>, Arc<DataModelIr>>,
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
visited: &mut HashSet<Arc<str>>,
) -> VariableType {
let inner = match &prop.kind {
@@ -202,7 +226,10 @@ impl DataModelIr {
PropertyTypeIr::Number => VariableType::Number,
PropertyTypeIr::Boolean => VariableType::Bool,
PropertyTypeIr::Reference { .. } => VariableType::String,
PropertyTypeIr::Relationship { target } => Self::wire_object(target, entities, visited),
PropertyTypeIr::Relationship { target } => match dictionaries.get(target.as_ref()) {
Some(dict) if !entities.contains_key(target.as_ref()) => dict.enum_type(),
_ => Self::wire_object(target, entities, dictionaries, visited),
},
};
if prop.array {
inner.array()
@@ -214,6 +241,7 @@ impl DataModelIr {
pub(crate) fn wire_object(
name: &Arc<str>,
entities: &HashMap<Arc<str>, Arc<DataModelIr>>,
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
visited: &mut HashSet<Arc<str>>,
) -> VariableType {
if !visited.insert(name.clone()) {
@@ -224,7 +252,7 @@ impl DataModelIr {
for prop in &dm.properties {
fields.insert(
Rc::from(prop.name.as_ref()),
Self::wire_property_type(prop, entities, visited),
Self::wire_property_type(prop, entities, dictionaries, visited),
);
}
}
@@ -232,7 +260,7 @@ impl DataModelIr {
VariableType::Object(Rc::new(RefCell::new(fields)))
}
fn validate_identifier(name: &str) -> Result<(), &'static str> {
pub(crate) fn validate_identifier(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("is empty");
}
@@ -393,6 +421,86 @@ impl DataModelIr {
}
}
#[derive(Debug, Clone)]
pub struct DictionaryIr {
pub name: Arc<str>,
pub entries: Vec<DictionaryEntry>,
}
#[derive(Debug, Clone)]
pub struct DictionaryEntry {
pub value: Arc<str>,
pub label: Arc<str>,
}
impl DictionaryIr {
pub fn parse(
id: &Arc<str>,
doc: &DictionaryDoc,
policy_path: &Arc<str>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Self> {
let name = doc.name.trimmed();
if name.is_empty() {
diagnostics.push(Diagnostic::error(
DiagnosticCode::ParseError,
DiagnosticLocation::block(policy_path.clone(), id.clone()),
"dictionary is missing a name",
));
return None;
}
if let Err(reason) = DataModelIr::validate_identifier(&name) {
diagnostics.push(Diagnostic::error(
DiagnosticCode::InvalidName,
DiagnosticLocation::block(policy_path.clone(), id.clone()),
format!("dictionary name '{name}' {reason}"),
));
return None;
}
let mut entries: Vec<DictionaryEntry> = Vec::with_capacity(doc.entries.len());
for entry in &doc.entries {
let value = entry.value.trimmed();
if value.is_empty() {
continue;
}
if entries.iter().any(|e| e.value == value) {
diagnostics.push(Diagnostic::error(
DiagnosticCode::DuplicateEnumValue,
DiagnosticLocation::expression(
policy_path.clone(),
id.clone(),
entry.id.clone(),
None,
),
format!("duplicate value '{value}' in dictionary '{name}'"),
));
continue;
}
entries.push(DictionaryEntry {
value,
label: entry.label.trimmed(),
});
}
Some(DictionaryIr { name, entries })
}
pub fn values(&self) -> impl Iterator<Item = &Arc<str>> {
self.entries.iter().map(|e| &e.value)
}
pub(crate) fn enum_type(&self) -> VariableType {
VariableType::Enum(
Some(Rc::from(self.name.as_ref())),
self.entries
.iter()
.map(|e| Rc::from(e.value.as_ref()))
.collect(),
)
}
}
impl PropertyTypeIr {
pub(crate) fn to_schema_field_kind(&self, array: bool) -> SchemaFieldKind {
match self {
+6 -6
View File
@@ -19,12 +19,12 @@ mod workspace;
pub use raw::{BlockDoc, PolicyDocument};
pub use types::{
BlockExecution, BlockRef, Completion, ConditionalSchema, Cursor, CursorTarget, DependencyNode,
Diagnostic, DiagnosticCode, DiagnosticLocation, DiscriminantVariant, DiscriminatedUnion,
EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult,
ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, InputValidationError,
InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, ReferenceKind,
ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, Severity, Span, Trace,
WriteConflict, WriteTrace,
Diagnostic, DiagnosticCode, DiagnosticLocation, Dictionary, DictionaryEntryInfo,
DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest,
EvaluationError, EvaluationResult, ExpressionKind, FieldOrigin, GuardedProperty, InputProperty,
InputValidationError, InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind,
ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest,
Severity, Span, Trace, WriteConflict, WriteTrace,
};
pub use workspace::PolicyWorkspace;
+69 -1
View File
@@ -49,6 +49,8 @@ impl Db {
out.extend(self.data_model_diagnostics(path));
out.extend(self.dictionary_diagnostics(path));
out.extend(self.unreachable_reads_diagnostics(path));
out.extend(self.nested_iteration_diagnostics(path));
@@ -476,7 +478,10 @@ impl Db {
if let PropertyTypeIr::Relationship { target: t }
| PropertyTypeIr::Reference { target: t } = &prop.kind
{
if !known_entities.contains(t) && policy_path == target {
let dictionary_target =
matches!(prop.kind, PropertyTypeIr::Relationship { .. })
&& unit.dictionaries.contains_key(t);
if !known_entities.contains(t) && !dictionary_target && policy_path == target {
let owner = if is_global {
format!("global property '{}'", prop.name)
} else {
@@ -499,4 +504,67 @@ impl Db {
out
}
fn dictionary_diagnostics(&self, target: &Arc<str>) -> Vec<Diagnostic> {
let mut out = Vec::new();
let unit = self.unit(target);
let known_entities: HashSet<Arc<str>> = unit
.data_models
.iter()
.filter(|e| !e.ir.scope.is_global())
.map(|e| e.ir.name.clone())
.collect();
let global_property_names: HashSet<Arc<str>> = unit
.data_models
.iter()
.filter(|e| e.ir.scope.is_global())
.flat_map(|e| e.ir.properties.iter().map(|p| p.name.clone()))
.collect();
let mut first_by_name: HashMap<Arc<str>, (Arc<str>, Arc<str>)> = HashMap::default();
for entry in &unit.dictionary_blocks {
let name = &entry.ir.name;
if let Some((prev_policy, prev_block)) = first_by_name.get(name) {
if entry.policy_path == *target {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::block(
entry.policy_path.clone(),
entry.block_id.clone(),
),
format!(
"dictionary '{name}' is already defined in '{prev_policy}' (block '{prev_block}')"
),
));
}
continue;
}
first_by_name.insert(
name.clone(),
(entry.policy_path.clone(), entry.block_id.clone()),
);
if entry.policy_path != *target {
continue;
}
if known_entities.contains(name) {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()),
format!("dictionary name '{name}' collides with an entity of the same name"),
));
}
if global_property_names.contains(name) {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()),
format!(
"dictionary name '{name}' collides with a global property of the same name"
),
));
}
}
out
}
}
+42 -7
View File
@@ -8,8 +8,8 @@ use crate::policy::ir::DataModelIr;
use crate::policy::queries::dependency::{DependencyGraph, PathPrefix};
use crate::policy::queries::scope::PropertyScope;
use crate::policy::types::{
Entity, EntityField, FieldOrigin, Global, InputProperty, OutputProperty, PropertyKind,
ScopeRequest,
Dictionary, DictionaryEntryInfo, Entity, EntityField, FieldOrigin, Global, InputProperty,
OutputProperty, PropertyKind, ScopeRequest,
};
impl Db {
@@ -59,8 +59,12 @@ impl Db {
}
}
let mut visited: HashSet<Arc<str>> = HashSet::default();
let resolved_type =
DataModelIr::wire_property_type(&vp.property, entities_map, &mut visited);
let resolved_type = DataModelIr::wire_property_type(
&vp.property,
entities_map,
&unit.dictionaries,
&mut visited,
);
out.push(Global {
name: vp.property.name.clone(),
resolved_type,
@@ -99,6 +103,31 @@ impl Db {
out
}
pub fn dictionaries(&self, req: &ScopeRequest) -> Vec<Dictionary> {
let unit = self.unit(&req.policy_path);
let mut seen: HashSet<Arc<str>> = HashSet::default();
let mut out: Vec<Dictionary> = Vec::new();
for entry in &unit.dictionary_blocks {
if !seen.insert(entry.ir.name.clone()) {
continue;
}
out.push(Dictionary {
name: entry.ir.name.clone(),
source: entry.policy_path.clone(),
entries: entry
.ir
.entries
.iter()
.map(|e| DictionaryEntryInfo {
value: e.value.clone(),
label: e.label.clone(),
})
.collect(),
});
}
out
}
pub fn inputs(&self, req: &ScopeRequest) -> Vec<InputProperty> {
let unit = self.unit(&req.policy_path);
let visible = &unit.members;
@@ -119,6 +148,7 @@ impl Db {
resolved_type: DataModelIr::wire_property_type(
&vp.property,
entities,
&unit.dictionaries,
&mut visited,
),
}
@@ -130,7 +160,8 @@ impl Db {
continue;
}
let mut visited: HashSet<Arc<str>> = HashSet::default();
let entity_type = DataModelIr::wire_object(target, entities, &mut visited);
let entity_type =
DataModelIr::wire_object(target, entities, &unit.dictionaries, &mut visited);
if !matches!(entity_type, VariableType::Any) {
result.push(InputProperty {
path: target.clone(),
@@ -184,8 +215,12 @@ impl Db {
return None;
};
let mut visited: HashSet<Arc<str>> = HashSet::default();
let resolved_type =
DataModelIr::wire_property_type(&vp.property, entities, &mut visited);
let resolved_type = DataModelIr::wire_property_type(
&vp.property,
entities,
&unit.dictionaries,
&mut visited,
);
let origin = FieldOrigin::Schema {
source: vp.policy_path,
kind: vp.property.kind.to_schema_field_kind(vp.property.array),
+40 -9
View File
@@ -8,7 +8,7 @@ use zen_expression::variable::{Variable, VariableType};
use crate::policy::blocks::InstanceSource;
use crate::policy::db::{Db, Snapshot};
use crate::policy::ir::{DataModelIr, ParsedPolicy, Property, PropertyTypeIr};
use crate::policy::ir::{DataModelIr, DictionaryIr, ParsedPolicy, Property, PropertyTypeIr};
use crate::policy::queries::dependency::DependencyGraph;
use crate::policy::types::InstanceTarget;
@@ -288,6 +288,20 @@ impl Snapshot {
})
}
pub(crate) fn compute_dictionary_map(
all_parsed: &HashMap<Arc<str>, Arc<ParsedPolicy>>,
) -> HashMap<Arc<str>, Arc<DictionaryIr>> {
let mut sorted: Vec<(&Arc<str>, &Arc<ParsedPolicy>)> = all_parsed.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let mut out: HashMap<Arc<str>, Arc<DictionaryIr>> = HashMap::new();
for (_, parsed) in sorted {
for (_, dict) in parsed.policy.dictionaries() {
out.entry(dict.name.clone()).or_insert_with(|| dict.clone());
}
}
out
}
pub(crate) fn compute_entity_graph(
all_parsed: &HashMap<Arc<str>, Arc<ParsedPolicy>>,
entity_sources: &Arc<EntitySources>,
@@ -320,6 +334,7 @@ impl Snapshot {
entity_sources: &EntitySources,
) -> VariableType {
let mut entity_map: HashMap<Arc<str>, VariableType> = HashMap::new();
let dictionaries = Self::compute_dictionary_map(all_parsed);
let mut models: Vec<(Arc<str>, &DataModelIr)> =
Self::iter_data_models(all_parsed).collect();
@@ -336,7 +351,7 @@ impl Snapshot {
}
for (_, dm) in models.iter().filter(|(_, dm)| !dm.scope.is_global()) {
dm.wire_relationships(&entity_map);
dm.wire_relationships(&entity_map, &dictionaries);
}
for (entity_name, source) in entity_sources.iter() {
@@ -370,7 +385,7 @@ impl Snapshot {
for (_, dm) in models.iter().filter(|(_, dm)| dm.scope.is_global()) {
for prop in &dm.properties {
let key = Rc::from(prop.name.as_ref());
let value_type = prop.build_global_type(&entity_map);
let value_type = prop.build_global_type(&entity_map, &dictionaries);
scope_fields.entry(key).or_insert(value_type);
}
}
@@ -594,7 +609,11 @@ impl DataModelIr {
}
}
fn wire_relationships(&self, entity_map: &HashMap<Arc<str>, VariableType>) {
fn wire_relationships(
&self,
entity_map: &HashMap<Arc<str>, VariableType>,
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
) {
for prop in &self.properties {
let target_name = match &prop.kind {
PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => {
@@ -603,14 +622,20 @@ impl DataModelIr {
_ => continue,
};
let Some(target_entity) = entity_map.get(target_name.as_ref()) else {
continue;
let target_type = match entity_map.get(target_name.as_ref()) {
Some(target_entity) => target_entity.shallow_clone(),
None => match dictionaries.get(target_name.as_ref()) {
Some(dict) if matches!(prop.kind, PropertyTypeIr::Relationship { .. }) => {
dict.enum_type()
}
_ => continue,
},
};
let mut final_type = if prop.array {
target_entity.shallow_clone().array()
target_type.array()
} else {
target_entity.shallow_clone()
target_type
};
if prop.optional {
final_type = VariableType::Nullable(Rc::new(final_type));
@@ -644,6 +669,7 @@ impl Property {
pub(crate) fn build_global_type(
&self,
entity_map: &HashMap<Arc<str>, VariableType>,
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
) -> VariableType {
let inner = match &self.kind {
PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String,
@@ -655,7 +681,12 @@ impl Property {
PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => {
match entity_map.get(target.as_ref()) {
Some(t) => t.shallow_clone(),
None => VariableType::Any,
None => match dictionaries.get(target.as_ref()) {
Some(dict) if matches!(self.kind, PropertyTypeIr::Relationship { .. }) => {
dict.enum_type()
}
_ => VariableType::Any,
},
}
}
};
+34 -1
View File
@@ -34,6 +34,10 @@ pub enum BlockDoc {
id: Arc<str>,
data: DataModelDoc,
},
Dictionary {
id: Arc<str>,
data: DictionaryDoc,
},
Ignored(serde_json::Value),
}
@@ -44,7 +48,8 @@ impl BlockDoc {
| Self::DecisionTable { id, .. }
| Self::Expression { id, .. }
| Self::Match { id, .. }
| Self::DataModel { id, .. } => Some(id),
| Self::DataModel { id, .. }
| Self::Dictionary { id, .. } => Some(id),
Self::Ignored(value) => value.get("id").and_then(serde_json::Value::as_str),
}
}
@@ -78,6 +83,10 @@ impl BlockDoc {
id,
data: serde_json::from_value(data)?,
}),
BlockTag::Dictionary => Ok(Self::Dictionary {
id,
data: serde_json::from_value(data)?,
}),
}
}
}
@@ -124,6 +133,9 @@ impl Serialize for BlockDoc {
Self::DataModel { id, data } => {
TaggedBlockRef::new(BlockTag::DataModel, id, data).serialize(serializer)
}
Self::Dictionary { id, data } => {
TaggedBlockRef::new(BlockTag::Dictionary, id, data).serialize(serializer)
}
Self::Ignored(value) => value.serialize(serializer),
}
}
@@ -136,6 +148,7 @@ enum BlockTag {
Expression,
Match,
DataModel,
Dictionary,
}
impl BlockTag {
@@ -146,6 +159,7 @@ impl BlockTag {
"expression" => Some(Self::Expression),
"match" => Some(Self::Match),
"dataModel" => Some(Self::DataModel),
"dictionary" => Some(Self::Dictionary),
_ => None,
}
}
@@ -157,6 +171,7 @@ impl BlockTag {
Self::Expression => "expression",
Self::Match => "match",
Self::DataModel => "dataModel",
Self::Dictionary => "dictionary",
}
}
}
@@ -227,6 +242,24 @@ pub struct PropertyDoc {
pub optional: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryDoc {
pub name: Arc<str>,
#[serde(default)]
pub entries: Vec<DictionaryEntryDoc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryEntryDoc {
#[serde(default)]
pub id: Arc<str>,
pub value: Arc<str>,
#[serde(default)]
pub label: Arc<str>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PropertyTypeDoc {
+4 -4
View File
@@ -18,8 +18,8 @@ pub use nl::NlExpression;
pub use request::{EvaluateRequest, ScopeRequest};
pub use result::{
BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema,
DecisionTableExtras, DependencyNode, DiscriminantVariant, DiscriminatedUnion, Entity,
EntityField, EvaluationResult, FieldOrigin, Global, GuardedProperty, InputProperty,
InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind, SchemaGroup, Trace,
WriteConflict, WriteTrace,
DecisionTableExtras, DependencyNode, Dictionary, DictionaryEntryInfo, DiscriminantVariant,
DiscriminatedUnion, Entity, EntityField, EvaluationResult, FieldOrigin, Global,
GuardedProperty, InputProperty, InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind,
SchemaGroup, Trace, WriteConflict, WriteTrace,
};
+3 -1
View File
@@ -29,7 +29,9 @@ impl NlExpression {
let unary = matches!(kind, ExpressionKind::Unary);
let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope);
if unary {
result.subject_type = Some(scope.get("$"));
let subject = scope.get("$");
result.subject_options = is.nl_subject_options(&subject);
result.subject_type = Some(subject);
}
Self {
policy_path: policy_path.clone(),
+15
View File
@@ -226,6 +226,21 @@ pub struct Global {
pub origin: FieldOrigin,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Dictionary {
pub name: Arc<str>,
pub source: Arc<str>,
pub entries: Vec<DictionaryEntryInfo>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryEntryInfo {
pub value: Arc<str>,
pub label: Arc<str>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EntityField {
+12 -1
View File
@@ -4,7 +4,7 @@ use ahash::{HashMap, HashMapExt, HashSet};
use zen_expression::variable::Variable;
use crate::policy::db::Db;
use crate::policy::ir::{DataModelIr, Property, PropertyTypeIr};
use crate::policy::ir::{DataModelIr, DictionaryIr, Property, PropertyTypeIr};
use crate::policy::refs::RefPoolIndex;
use crate::policy::types::InputValidationError;
use crate::policy::MAX_RECURSION_DEPTH;
@@ -21,6 +21,7 @@ impl Db {
globals,
roots,
ref_targets,
dictionaries: self.unit(policy_path).dictionaries.clone(),
}
}
@@ -70,6 +71,7 @@ pub(crate) struct InputSchema {
globals: HashMap<Arc<str>, Property>,
roots: HashSet<Arc<str>>,
ref_targets: HashSet<Arc<str>>,
dictionaries: HashMap<Arc<str>, Arc<DictionaryIr>>,
}
impl InputSchema {
@@ -77,6 +79,7 @@ impl InputSchema {
let ref_pools = RefPoolIndex::from_input(input, self.ref_targets.iter().cloned());
let mut validator = InputValidator {
entities: &self.entities,
dictionaries: &self.dictionaries,
ref_pools: &ref_pools,
errors: Vec::new(),
depth: 0,
@@ -113,6 +116,7 @@ impl InputSchema {
struct InputValidator<'a> {
entities: &'a HashMap<Arc<str>, Arc<DataModelIr>>,
dictionaries: &'a HashMap<Arc<str>, Arc<DictionaryIr>>,
ref_pools: &'a RefPoolIndex,
errors: Vec<InputValidationError>,
depth: usize,
@@ -220,6 +224,13 @@ impl InputValidator<'_> {
return;
}
PropertyTypeIr::Relationship { target } => {
if !self.entities.contains_key(target) {
if let Some(dict) = self.dictionaries.get(target) {
let values: Vec<Arc<str>> = dict.values().cloned().collect();
self.validate_enum(value, &values, path);
return;
}
}
self.validate_entity(value, target, path);
return;
}
+8 -4
View File
@@ -6,10 +6,10 @@ use crate::policy::raw::PolicyDocument;
use zen_expression::nl::NlResult;
use crate::policy::types::{
Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, EngineEdit, Entity,
EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty, InspectResult,
NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget, ScopeRequest,
WriteConflict,
Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, Dictionary, EngineEdit,
Entity, EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty,
InspectResult, NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget,
ScopeRequest, WriteConflict,
};
pub struct PolicyWorkspace {
@@ -64,6 +64,10 @@ impl PolicyWorkspace {
self.db.globals(req)
}
pub fn dictionaries(&self, req: &ScopeRequest) -> Vec<Dictionary> {
self.db.dictionaries(req)
}
pub fn inputs(&self, req: &ScopeRequest) -> Vec<InputProperty> {
self.db.inputs(req)
}
File diff suppressed because it is too large Load Diff
+541
View File
@@ -0,0 +1,541 @@
use serde_json::json;
use std::sync::Arc;
use zen_engine::policy::{
Cursor, CursorTarget, EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest,
};
use zen_expression::nl::{EditHint, NlTokenKind};
use zen_expression::variable::Variable;
fn dictionary_block(id: &str, name: &str, entries: &[(&str, &str)]) -> serde_json::Value {
json!({
"id": id,
"type": "dictionary",
"props": {
"data": {
"name": name,
"entries": entries.iter().enumerate().map(|(i, (value, label))| json!({
"id": format!("e{i}"),
"value": value,
"label": label,
})).collect::<Vec<_>>(),
}
}
})
}
fn tier_dictionary() -> serde_json::Value {
dictionary_block(
"dict1",
"customerTier",
&[("VIP", "Very important"), ("STD", "Standard")],
)
}
fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value {
json!({
"id": id,
"type": "expression",
"props": { "data": { "key": key, "value": value } }
})
}
fn workspace_with(blocks: Vec<serde_json::Value>) -> PolicyWorkspace {
let mut ws = PolicyWorkspace::new();
ws.set_policy(
"main",
serde_json::from_value(json!({ "blocks": blocks })).unwrap(),
);
ws
}
fn evaluate(
ws: &PolicyWorkspace,
input: serde_json::Value,
) -> Result<serde_json::Value, EvaluationError> {
let result = ws.evaluate(&EvaluateRequest {
policy_path: Arc::from("main"),
input: Variable::from(input),
goals: Vec::new(),
trace: false,
})?;
Ok(result.output.to_value())
}
#[test]
fn dictionary_is_not_referenceable_in_expressions() {
for member in ["customerTier.VIP", "customerTier.GOLD"] {
let ws = workspace_with(vec![
tier_dictionary(),
expression_block("e1", "tier", member),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("customerTier")),
"expected '{member}' to be an unknown property, got: {diagnostics:?}"
);
}
}
#[test]
fn dictionary_typed_field_compares_as_plain_string() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
assert_eq!(output["customer"]["isVip"], json!(true));
}
#[test]
fn duplicate_values_are_diagnosed() {
let ws = workspace_with(vec![dictionary_block(
"dict1",
"customerTier",
&[("VIP", "One"), ("VIP", "Two")],
)]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("duplicate value 'VIP'")),
"got: {diagnostics:?}"
);
}
#[test]
fn dictionary_name_collisions_are_diagnosed() {
let ws = workspace_with(vec![
tier_dictionary(),
dictionary_block("dict2", "customerTier", &[("A", "")]),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("already defined")),
"got: {diagnostics:?}"
);
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customerTier",
"properties": [
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
]
}}
}),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("collides with an entity")),
"got: {diagnostics:?}"
);
}
#[test]
fn data_model_property_can_reference_dictionary() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(
!diagnostics
.iter()
.any(|d| format!("{d:?}").contains("unknown entity")),
"dictionary target must not be an unknown entity: {diagnostics:?}"
);
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
assert_eq!(output["customer"]["isVip"], json!(true));
let invalid = evaluate(&ws, json!({ "customer": { "tier": "GOLD" } }));
assert!(
matches!(invalid, Err(EvaluationError::InputValidationFailed { .. })),
"value outside the dictionary must fail input validation"
);
}
#[test]
fn dictionary_membership_is_validated_for_arrays_and_globals() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tiers", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
]
}}
}),
json!({
"id": "dm2",
"type": "dataModel",
"props": { "data": {
"name": "global",
"scope": "global",
"properties": [
{ "id": "p2", "name": "defaultTier", "type": "relationship", "target": "customerTier", "array": false, "optional": true }
]
}}
}),
expression_block("e1", "customer.first", "customer.tiers[0]"),
]);
let ok = evaluate(
&ws,
json!({ "customer": { "tiers": ["VIP", "STD"] }, "defaultTier": "STD" }),
)
.unwrap();
assert_eq!(ok["customer"]["first"], json!("VIP"));
let bad_element = evaluate(&ws, json!({ "customer": { "tiers": ["VIP", "GOLD"] } }));
assert!(matches!(
bad_element,
Err(EvaluationError::InputValidationFailed { .. })
));
let bad_global = evaluate(
&ws,
json!({ "customer": { "tiers": [] }, "defaultTier": "GOLD" }),
);
assert!(matches!(
bad_global,
Err(EvaluationError::InputValidationFailed { .. })
));
let not_a_string = evaluate(&ws, json!({ "customer": { "tiers": [42] } }));
assert!(matches!(
not_a_string,
Err(EvaluationError::InputValidationFailed { .. })
));
}
#[test]
fn input_key_matching_dictionary_name_is_plain_data() {
let ws = workspace_with(vec![
tier_dictionary(),
expression_block("e1", "echo", "input"),
]);
let output = evaluate(&ws, json!({ "input": 1, "customerTier": "boom" })).unwrap();
assert_eq!(output["echo"], json!(1));
assert_eq!(output["customerTier"], json!("boom"));
}
#[test]
fn dictionaries_query_exposes_labels() {
let ws = workspace_with(vec![tier_dictionary()]);
let dictionaries = ws.dictionaries(&ScopeRequest {
policy_path: Arc::from("main"),
goals: Vec::new(),
});
assert_eq!(dictionaries.len(), 1);
let dict = &dictionaries[0];
assert_eq!(dict.name.as_ref(), "customerTier");
assert_eq!(dict.entries.len(), 2);
assert_eq!(dict.entries[0].value.as_ref(), "VIP");
assert_eq!(dict.entries[0].label.as_ref(), "Very important");
}
#[test]
fn nl_tokenize_uses_dictionary_labels() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
json!({
"id": "dt1",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ],
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
"rules": [ { "_id": "row1", "in1": "'VIP'", "out1": "'vip'" } ]
}}
}),
]);
let cursor = Cursor {
policy_path: "main".into(),
block_id: "dt1".into(),
pos: 0,
target: CursorTarget::DecisionTableCell {
row: "row1".into(),
col: "in1".into(),
},
};
let result = ws.nl_tokenize(&cursor, "'VIP'").expect("cursor resolves");
let str_tok = result
.tokens
.iter()
.find(|t| matches!(t.token, NlTokenKind::Str { .. }))
.expect("string token present");
let Some(EditHint::Select { options }) = str_tok.hint else {
panic!("expected select hint, got {:?}", str_tok.hint);
};
let options = &result.enums[options as usize];
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
assert_eq!(labels, vec!["Very important", "Standard"]);
let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect();
assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]);
}
#[test]
fn nl_tokenize_empty_cell_exposes_labeled_subject_options() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
json!({
"id": "dt1",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ],
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
"rules": [ { "_id": "row1", "in1": "", "out1": "'vip'" } ]
}}
}),
]);
let cursor = Cursor {
policy_path: "main".into(),
block_id: "dt1".into(),
pos: 0,
target: CursorTarget::DecisionTableCell {
row: "row1".into(),
col: "in1".into(),
},
};
let result = ws.nl_tokenize(&cursor, "").expect("cursor resolves");
let options = result.subject_options.expect("subject options present");
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
assert_eq!(labels, vec!["Very important", "Standard"]);
let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect();
assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]);
let batch = ws.nl("main");
let cell = batch
.iter()
.find(|e| {
e.block_id.as_ref() == "dt1"
&& matches!(&e.target, CursorTarget::DecisionTableCell { col, .. } if col.as_ref() == "in1")
})
.expect("batch projection for the table cell");
let batch_options = cell
.result
.subject_options
.as_ref()
.expect("batch projection carries subject options");
let batch_labels: Vec<&str> = batch_options.iter().map(|o| o.label.as_str()).collect();
assert_eq!(batch_labels, vec!["Very important", "Standard"]);
}
#[test]
fn nl_tokenize_closure_membership_gets_enum_multiselect() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
]
}}
}),
json!({
"id": "dt1",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ],
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
"rules": [ { "_id": "row1", "in1": "all(['VIP'], # in $)", "out1": "'vip'" } ]
}}
}),
]);
let cursor = Cursor {
policy_path: "main".into(),
block_id: "dt1".into(),
pos: 0,
target: CursorTarget::DecisionTableCell {
row: "row1".into(),
col: "in1".into(),
},
};
let result = ws
.nl_tokenize(&cursor, "all(['VIP'], # in $)")
.expect("cursor resolves");
let list = result
.tokens
.iter()
.find(|t| matches!(&t.token, NlTokenKind::EnumList { .. }))
.expect("array literal projects as enum list");
let NlTokenKind::EnumList { selected } = &list.token else {
unreachable!();
};
assert_eq!(selected.as_slice(), [Box::from("VIP")]);
let Some(EditHint::MultiSelect { options }) = list.hint else {
panic!("expected multi-select hint, got {:?}", list.hint);
};
let options = &result.enums[options as usize];
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
assert_eq!(labels, vec!["Very important", "Standard"]);
}
#[test]
fn nl_tokenize_reversed_membership_and_contains_get_enum_hints() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
]
}}
}),
json!({
"id": "dt1",
"type": "decisionTable",
"props": { "data": {
"hitPolicy": "first",
"inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ],
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
"rules": [ { "_id": "row1", "in1": "'VIP' in $", "out1": "'vip'" } ]
}}
}),
]);
let cursor = Cursor {
policy_path: "main".into(),
block_id: "dt1".into(),
pos: 0,
target: CursorTarget::DecisionTableCell {
row: "row1".into(),
col: "in1".into(),
},
};
let assert_labeled_select = |result: zen_expression::nl::NlResult| {
let str_tok = result
.tokens
.iter()
.find(|t| matches!(t.token, NlTokenKind::Str { .. }))
.expect("string token present");
let Some(EditHint::Select { options }) = str_tok.hint else {
panic!("expected select hint, got {:?}", str_tok.hint);
};
let labels: Vec<&str> = result.enums[options as usize]
.iter()
.map(|o| o.label.as_str())
.collect();
assert_eq!(labels, vec!["Very important", "Standard"]);
};
assert_labeled_select(
ws.nl_tokenize(&cursor, "'VIP' in $")
.expect("cursor resolves"),
);
assert_labeled_select(
ws.nl_tokenize(&cursor, "contains($, 'VIP')")
.expect("cursor resolves"),
);
}
#[test]
fn dictionary_block_round_trips_through_wire_format() {
let block_json = tier_dictionary();
let block: zen_engine::policy::BlockDoc = serde_json::from_value(block_json.clone()).unwrap();
let serialized = serde_json::to_value(&block).unwrap();
assert_eq!(serialized, block_json);
}
#[test]
fn non_member_literal_comparison_is_diagnosed() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.flag", "customer.tier == 'GOLD'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("GOLD")),
"expected non-member literal to be diagnosed, got: {diagnostics:?}"
);
}
+18 -2
View File
@@ -53,10 +53,13 @@ pub struct ExpressionAnalysis {
pub diagnostics: Vec<Diagnostic>,
}
pub type NlLabelResolver = Rc<dyn Fn(&str, &str) -> Option<String>>;
pub struct IntelliSense {
arena: Bump,
lexer: Lexer,
strict: bool,
nl_labels: Option<NlLabelResolver>,
}
impl IntelliSense {
@@ -65,6 +68,7 @@ impl IntelliSense {
arena: Bump::new(),
lexer: Lexer::new(),
strict: false,
nl_labels: None,
}
}
@@ -73,6 +77,10 @@ impl IntelliSense {
self
}
pub fn set_nl_labels(&mut self, labels: Option<NlLabelResolver>) {
self.nl_labels = labels;
}
pub fn completions(
&mut self,
source: &str,
@@ -203,11 +211,17 @@ impl IntelliSense {
let mut result =
self.nl_tokenize_scoped(&request.id, &request.expression, request.unary, &scope);
if request.unary {
result.subject_type = Some(scope.get("$"));
let subject = scope.get("$");
result.subject_options = self.nl_subject_options(&subject);
result.subject_type = Some(subject);
}
result
}
pub fn nl_subject_options(&self, subject: &VariableType) -> Option<Vec<crate::nl::EnumOption>> {
crate::nl::subject_enum_options(subject, self.nl_labels.as_ref())
}
pub fn nl_tokenize_scoped(
&mut self,
id: &str,
@@ -221,6 +235,7 @@ impl IntelliSense {
enums: Vec::new(),
diagnostics: Vec::new(),
subject_type: None,
subject_options: None,
};
self.arena.reset();
@@ -270,7 +285,8 @@ impl IntelliSense {
let type_data = TypesProvider::generate(ast, scope, self.strict);
collect_type_diagnostics(ast, &type_data, &metadata, &mut result.diagnostics);
let (tokens, enums) = Projector::new(source, &type_data, &metadata, unary).run(ast);
let (tokens, enums) =
Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()).run(ast);
result.tokens = tokens;
result.enums = enums;
result
+35
View File
@@ -4,8 +4,10 @@ pub mod token;
pub use token::{EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym};
use serde::Serialize;
use std::rc::Rc;
use crate::intellisense::diagnostic::Diagnostic;
use crate::intellisense::NlLabelResolver;
use crate::variable::VariableType;
pub fn encode_string(value: &str) -> Option<String> {
@@ -18,6 +20,37 @@ pub fn encode_string(value: &str) -> Option<String> {
}
}
pub(crate) fn enum_options(
name: Option<&str>,
values: &[Rc<str>],
labels: Option<&NlLabelResolver>,
) -> Vec<EnumOption> {
values
.iter()
.map(|v| EnumOption {
label: labels
.zip(name)
.and_then(|(resolve, n)| resolve(n, v))
.filter(|l| !l.is_empty())
.unwrap_or_else(|| v.to_string()),
source: encode_string(v),
})
.collect()
}
pub(crate) fn subject_enum_options(
subject: &VariableType,
labels: Option<&NlLabelResolver>,
) -> Option<Vec<EnumOption>> {
match subject {
VariableType::Enum(name, values) => Some(enum_options(name.as_deref(), values, labels)),
VariableType::Nullable(inner) | VariableType::Array(inner) => {
subject_enum_options(inner, labels)
}
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct NlRequest {
pub id: String,
@@ -35,4 +68,6 @@ pub struct NlResult {
pub diagnostics: Vec<Diagnostic>,
#[serde(skip)]
pub subject_type: Option<VariableType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_options: Option<Vec<EnumOption>>,
}
+66 -35
View File
@@ -2,9 +2,8 @@ use std::rc::Rc;
use crate::functions::FunctionKind;
use crate::intellisense::type_provider::TypesProvider;
use crate::intellisense::AstMetadata;
use crate::lexer::{Bracket, Operator};
use crate::nl::encode_string;
use crate::intellisense::{AstMetadata, NlLabelResolver};
use crate::lexer::{Bracket, ComparisonOperator, Operator};
use crate::nl::token::{
EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym,
};
@@ -21,6 +20,7 @@ pub(crate) struct Projector<'a> {
types: &'a TypesProvider,
metadata: &'a AstMetadata,
unary: bool,
labels: Option<NlLabelResolver>,
aliases: Vec<AliasScope>,
pending_elide: bool,
pending_implied: bool,
@@ -34,12 +34,14 @@ impl<'a> Projector<'a> {
types: &'a TypesProvider,
metadata: &'a AstMetadata,
unary: bool,
labels: Option<NlLabelResolver>,
) -> Self {
Self {
source,
types,
metadata,
unary,
labels,
aliases: Vec::new(),
pending_elide: false,
pending_implied: false,
@@ -67,8 +69,8 @@ impl<'a> Projector<'a> {
),
Node::String(value) => {
let hint = match Self::enum_values(expected.as_ref()) {
Some(values) => Some(EditHint::Select {
options: self.intern_enum(&values),
Some((name, values)) => Some(EditHint::Select {
options: self.intern_enum(name.as_deref(), &values),
}),
None if Self::expects_date(expected.as_ref()) => Some(EditHint::DatePicker),
None => None,
@@ -141,7 +143,14 @@ impl<'a> Projector<'a> {
operator,
right,
} => {
let (exp_left, exp_right) = self.operand_expectations(*operator, left, right);
let (exp_left, exp_right) = if matches!(
operator,
Operator::Logical(crate::lexer::LogicalOperator::NullishCoalescing)
) {
(expected.clone(), expected)
} else {
self.operand_expectations(*operator, left, right)
};
let context_subject = matches!(operator, Operator::Comparison(_))
&& matches!(left, Node::Identifier(name) if *name == "$");
if !context_subject && !self.is_elided_subject(left) {
@@ -195,14 +204,14 @@ impl<'a> Projector<'a> {
NlTokenKind::Word { sym: WordSym::Then },
(self.span_of(condition).1, self.span_of(on_true).0),
);
self.project(on_true, None);
self.project(on_true, expected.clone());
self.push(
NlTokenKind::Word {
sym: WordSym::Otherwise,
},
(self.span_of(on_true).1, self.span_of(on_false).0),
);
self.project(on_false, None);
self.project(on_false, expected);
}
Node::Interval {
@@ -242,7 +251,7 @@ impl<'a> Projector<'a> {
Node::Array(items) => {
let enum_domain = Self::enum_values(expected.as_ref())
.filter(|_| items.iter().all(|item| matches!(item, Node::String(_))));
if let Some(values) = enum_domain {
if let Some((name, values)) = enum_domain {
let selected = items
.iter()
.filter_map(|item| match item {
@@ -251,7 +260,7 @@ impl<'a> Projector<'a> {
})
.collect();
let hint = EditHint::MultiSelect {
options: self.intern_enum(&values),
options: self.intern_enum(name.as_deref(), &values),
};
self.push_hint(NlTokenKind::EnumList { selected }, span, Some(hint));
return;
@@ -355,7 +364,11 @@ impl<'a> Projector<'a> {
},
(self.span_of(left).1, self.span_of(right).0),
);
self.project(right, None);
let needle_expected = match self.type_of(left) {
haystack @ VariableType::Array(_) => Some(haystack),
_ => None,
};
self.project(right, needle_expected);
return;
}
if !(self.unary && sym.as_ref() == "bool" && self.out.is_empty()) {
@@ -393,8 +406,9 @@ impl<'a> Projector<'a> {
);
self.push(NlTokenKind::Word { sym: WordSym::In }, (span.0, span.0));
}
let membership = self.closure_membership_expectation(arguments, alias);
if let Some(collection) = arguments.first() {
self.project(collection, None);
self.project(collection, membership);
}
let leftmost = match arguments.get(1) {
Some(Node::Closure { body, .. }) => Some(Self::leftmost_leaf(body)),
@@ -438,6 +452,36 @@ impl<'a> Projector<'a> {
}
}
fn closure_membership_expectation(
&self,
arguments: &[&Node],
alias: Option<&str>,
) -> Option<VariableType> {
let Some(Node::Closure { body, .. }) = arguments.get(1) else {
return None;
};
let mut node: &Node = body;
while let Node::Parenthesized(inner) = node {
node = inner;
}
let Node::Binary {
left,
operator: Operator::Comparison(cmp),
right,
} = node
else {
return None;
};
if !matches!(cmp, ComparisonOperator::In | ComparisonOperator::NotIn) {
return None;
}
if !Self::is_binding_leaf(left, alias) {
return None;
}
let rhs = self.type_of(right);
Self::enum_values(Some(&rhs)).map(|(name, values)| VariableType::Enum(name, values))
}
fn leftmost_leaf<'n>(body: &'n Node<'n>) -> &'n Node<'n> {
let mut node = body;
loop {
@@ -561,14 +605,10 @@ impl<'a> Projector<'a> {
left: &Node,
right: &Node,
) -> (Option<VariableType>, Option<VariableType>) {
use crate::lexer::ComparisonOperator::{In, NotIn};
let Operator::Comparison(comparison) = operator else {
let Operator::Comparison(_) = operator else {
return (None, None);
};
match comparison {
In | NotIn => (None, Some(self.type_of(left))),
_ => (Some(self.type_of(right)), Some(self.type_of(left))),
}
(Some(self.type_of(right)), Some(self.type_of(left)))
}
fn code(&mut self, span: (u32, u32)) {
@@ -616,8 +656,8 @@ impl<'a> Projector<'a> {
VariableType::Object(_) => TypeTag::Object,
VariableType::Null => TypeTag::Null,
VariableType::Any => TypeTag::Unknown,
VariableType::Enum(_, values) => TypeTag::Enum {
index: self.intern_enum(values),
VariableType::Enum(name, values) => TypeTag::Enum {
index: self.intern_enum(name.as_deref(), values),
},
VariableType::Array(inner) => TypeTag::Array {
items: Box::new(self.tag_of(inner)),
@@ -626,28 +666,19 @@ impl<'a> Projector<'a> {
}
}
fn intern_enum(&mut self, values: &[Rc<str>]) -> u32 {
let existing = self.enums.iter().position(|e| {
e.len() == values.len() && e.iter().zip(values).all(|(a, b)| a.label == b.as_ref())
});
fn intern_enum(&mut self, name: Option<&str>, values: &[Rc<str>]) -> u32 {
let options = crate::nl::enum_options(name, values, self.labels.as_ref());
let existing = self.enums.iter().position(|e| *e == options);
if let Some(index) = existing {
return index as u32;
}
self.enums.push(
values
.iter()
.map(|v| EnumOption {
label: v.to_string(),
source: encode_string(v),
})
.collect(),
);
self.enums.push(options);
(self.enums.len() - 1) as u32
}
fn enum_values(ty: Option<&VariableType>) -> Option<Vec<Rc<str>>> {
fn enum_values(ty: Option<&VariableType>) -> Option<(Option<Rc<str>>, Vec<Rc<str>>)> {
match ty? {
VariableType::Enum(_, values) => Some(values.clone()),
VariableType::Enum(name, values) => Some((name.clone(), values.clone())),
VariableType::Nullable(inner) | VariableType::Array(inner) => {
Self::enum_values(Some(inner))
}