diff --git a/core/engine/src/policy/blocks/context.rs b/core/engine/src/policy/blocks/context.rs index 8f5e5533..5e8054e5 100644 --- a/core/engine/src/policy/blocks/context.rs +++ b/core/engine/src/policy/blocks/context.rs @@ -56,6 +56,8 @@ pub struct ExecutionError { pub source: IsolateError, } +pub type SharedDictionaryTypes = Rc, VariableType>>; + pub struct AnalysisContext { scope: VariableType, policy_path: Arc, @@ -65,6 +67,7 @@ pub struct AnalysisContext { diagnostics: Vec, pass: AnalysisPass, intellisense: SharedIntelliSense, + dictionary_types: SharedDictionaryTypes, } impl AnalysisContext { @@ -74,6 +77,7 @@ impl AnalysisContext { block_id: Arc, intellisense: SharedIntelliSense, pass: AnalysisPass, + dictionary_types: SharedDictionaryTypes, ) -> Self { Self { scope, @@ -84,6 +88,7 @@ impl AnalysisContext { diagnostics: Vec::new(), pass, intellisense, + dictionary_types, } } @@ -91,6 +96,10 @@ impl AnalysisContext { &self.scope } + pub(super) fn dictionary_types(&self) -> &ahash::HashMap, VariableType> { + &self.dictionary_types + } + pub fn analyze_standard( &mut self, source: &Arc, diff --git a/core/engine/src/policy/blocks/decision_table.rs b/core/engine/src/policy/blocks/decision_table.rs index 3ba25f19..13007920 100644 --- a/core/engine/src/policy/blocks/decision_table.rs +++ b/core/engine/src/policy/blocks/decision_table.rs @@ -228,6 +228,80 @@ pub struct OutputColumn { pub field: Arc, pub raw_field: Arc, pub collect: bool, + pub declared: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeclaredBase { + String, + Number, + Bool, + Date, + Dictionary(Arc), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredType { + pub base: DeclaredBase, + pub array: bool, +} + +impl DeclaredType { + pub(crate) fn parse(raw: &str) -> Result, String> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + let (base_raw, array) = match raw.strip_suffix("[]") { + Some(base) => (base.trim_end(), true), + None => (raw, false), + }; + let base = match base_raw { + "string" => DeclaredBase::String, + "number" => DeclaredBase::Number, + "boolean" => DeclaredBase::Bool, + "date" => DeclaredBase::Date, + other => { + if crate::policy::ir::DataModelIr::validate_identifier(other).is_err() { + return Err(format!( + "invalid output type '{raw}': expected string, number, boolean, date or a dictionary name, optionally suffixed with []" + )); + } + DeclaredBase::Dictionary(Arc::from(other)) + } + }; + Ok(Some(DeclaredType { base, array })) + } + + pub(crate) fn resolve( + &self, + dictionaries: &HashMap, VariableType>, + ) -> Option { + let base = match &self.base { + DeclaredBase::String => VariableType::String, + DeclaredBase::Number => VariableType::Number, + DeclaredBase::Bool => VariableType::Bool, + DeclaredBase::Date => VariableType::Date, + DeclaredBase::Dictionary(name) => dictionaries.get(name)?.shallow_clone(), + }; + Some(if self.array { base.array() } else { base }) + } +} + +impl std::fmt::Display for DeclaredType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.base { + DeclaredBase::String => write!(f, "string")?, + DeclaredBase::Number => write!(f, "number")?, + DeclaredBase::Bool => write!(f, "boolean")?, + DeclaredBase::Date => write!(f, "date")?, + DeclaredBase::Dictionary(name) => write!(f, "{name}")?, + } + if self.array { + write!(f, "[]")?; + } + Ok(()) + } } impl DecisionTableIr { @@ -315,11 +389,28 @@ impl DecisionTableIr { Arc::from(path) }; + let declared = match DeclaredType::parse(col.column_type.as_deref().unwrap_or("")) { + Ok(declared) => declared, + Err(message) => { + cx.target_error( + &col.id, + CursorTarget::DecisionTableHead { + col: col.id.clone(), + }, + None, + DiagnosticCode::TypeMismatch, + message, + ); + None + } + }; + OutputColumn { id: col.id.clone(), field, raw_field, collect, + declared, } } @@ -424,29 +515,66 @@ impl DecisionTableIr { continue; } + let declared = (cx.is_enriched()) + .then(|| self.resolve_declared(col, cx)) + .flatten(); + let mut cell_types: Vec = Vec::new(); for rule in &self.rules { let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { continue; }; let analysis = cx.analyze_standard(cell, Some(col.id.clone())); - cell_types.push(analysis.return_type.clone()); - } - - if !col.collect && cx.is_enriched() && !self.column_covered(col, cx, &input_field_types) - { - cell_types.push(VariableType::Null); + match &declared { + Some(expected) => { + let actual = &analysis.return_type; + if !actual.is_null() && !actual.satisfies(expected) { + let target = + rule.get(ROW_ID_KEY) + .map(|row| CursorTarget::DecisionTableCell { + row: row.clone(), + col: col.id.clone(), + }); + cx.error_with_target( + DiagnosticCode::TypeMismatch, + Some(col.id.clone()), + None, + target, + format!("output cell must be `{expected}`, got `{actual}`"), + ); + } + } + None => cell_types.push(analysis.return_type.clone()), + } } + let covered = col.collect + || !cx.is_enriched() + || self.column_covered(col, cx, &input_field_types); let target = Some(CursorTarget::DecisionTableHead { col: col.id.clone(), }); - let mut resolved = cx.merge_types( - &cell_types, - &col.field, - Some(col.id.clone()), - target.clone(), - ); + + let mut resolved = match declared { + Some(expected) => { + if covered { + expected + } else { + VariableType::Nullable(std::rc::Rc::new(expected)) + } + } + None => { + if !covered { + cell_types.push(VariableType::Null); + } + cx.merge_types( + &cell_types, + &col.field, + Some(col.id.clone()), + target.clone(), + ) + } + }; if col.collect { resolved = resolved.array(); @@ -456,6 +584,29 @@ impl DecisionTableIr { } } + fn resolve_declared( + &self, + col: &OutputColumn, + cx: &mut AnalysisContext, + ) -> Option { + let declared = col.declared.as_ref()?; + let resolved = declared.resolve(cx.dictionary_types()); + if resolved.is_none() { + cx.error_with_target( + DiagnosticCode::TypeMismatch, + Some(col.id.clone()), + None, + Some(CursorTarget::DecisionTableHead { + col: col.id.clone(), + }), + format!( + "unknown output type '{declared}': no dictionary with that name is in scope" + ), + ); + } + resolved + } + fn column_covered( &self, col: &OutputColumn, @@ -778,6 +929,7 @@ impl DecisionTableIr { block_id: &Arc, scope: &VariableType, is: &mut IntelliSense, + dictionaries: &HashMap, VariableType>, ) -> Vec { let mut out = Vec::new(); let mut input_scopes: HashMap, (ExpressionKind, VariableType)> = @@ -836,7 +988,8 @@ impl DecisionTableIr { } for col in &self.outputs { let cell: &str = rule.get(&col.id).map(|c| c.as_ref()).unwrap_or(""); - out.push(NlExpression::project( + let expected = col.declared.as_ref().and_then(|d| d.resolve(dictionaries)); + out.push(NlExpression::project_expected( is, policy_path, block_id, @@ -847,6 +1000,7 @@ impl DecisionTableIr { ExpressionKind::Standard, cell, scope, + expected.as_ref(), )); } } @@ -859,19 +1013,29 @@ impl DecisionTableIr { cursor: &Cursor, scope: VariableType, is: &mut IntelliSense, - ) -> (ExpressionKind, VariableType) { + dictionaries: &HashMap, VariableType>, + ) -> (ExpressionKind, VariableType, Option) { let CursorTarget::DecisionTableCell { col, .. } = &cursor.target else { - return (ExpressionKind::Standard, scope); + return (ExpressionKind::Standard, scope, None); }; - let Some(ColumnRef::Input(column)) = self.column_by_id(col) else { - return (ExpressionKind::Standard, scope); - }; - match column.field.as_ref().filter(|f| !f.is_empty()) { - Some(field) => { - let field_type = is.analyze(field.as_ref(), &scope).return_type.clone(); - (ExpressionKind::Unary, scope.with_dollar(&field_type)) + match self.column_by_id(col) { + Some(ColumnRef::Input(column)) => { + match column.field.as_ref().filter(|f| !f.is_empty()) { + Some(field) => { + let field_type = is.analyze(field.as_ref(), &scope).return_type.clone(); + (ExpressionKind::Unary, scope.with_dollar(&field_type), None) + } + None => (ExpressionKind::Standard, scope, None), + } + } + Some(ColumnRef::Output(column)) => { + let expected = column + .declared + .as_ref() + .and_then(|d| d.resolve(dictionaries)); + (ExpressionKind::Standard, scope, expected) } - None => (ExpressionKind::Standard, scope), + None => (ExpressionKind::Standard, scope, None), } } diff --git a/core/engine/src/policy/blocks/mod.rs b/core/engine/src/policy/blocks/mod.rs index 872e0241..62141e69 100644 --- a/core/engine/src/policy/blocks/mod.rs +++ b/core/engine/src/policy/blocks/mod.rs @@ -29,7 +29,7 @@ pub use assertion::{AssertionDoc, AssertionIr}; pub(crate) use context::IntelliSenseSource; pub use context::{ AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation, - InstanceSource, PropertyRead, SharedIntelliSense, WriteTarget, + InstanceSource, PropertyRead, SharedDictionaryTypes, SharedIntelliSense, WriteTarget, }; pub(crate) use decision_table::TableSelection; pub use decision_table::{DecisionTableDoc, DecisionTableIr}; @@ -237,8 +237,9 @@ impl Block { policy_path: &Arc, scope: &VariableType, is: &mut IntelliSense, + dictionaries: &HashMap, VariableType>, ) -> Vec { - self.kind.nl(policy_path, &self.id, scope, is) + self.kind.nl(policy_path, &self.id, scope, is, dictionaries) } pub fn nl_scope( @@ -246,10 +247,11 @@ impl Block { cursor: &Cursor, scope: VariableType, is: &mut IntelliSense, - ) -> (ExpressionKind, VariableType) { + dictionaries: &HashMap, VariableType>, + ) -> (ExpressionKind, VariableType, Option) { match &self.kind { - BlockKind::DecisionTable(d) => d.nl_scope(cursor, scope, is), - _ => (ExpressionKind::Standard, scope), + BlockKind::DecisionTable(d) => d.nl_scope(cursor, scope, is, dictionaries), + _ => (ExpressionKind::Standard, scope, None), } } } @@ -347,10 +349,11 @@ impl BlockKind { block_id: &Arc, scope: &VariableType, is: &mut IntelliSense, + dictionaries: &HashMap, VariableType>, ) -> Vec { match self { BlockKind::Assertion(a) => a.nl(policy_path, block_id, scope, is), - BlockKind::DecisionTable(d) => d.nl(policy_path, block_id, scope, is), + BlockKind::DecisionTable(d) => d.nl(policy_path, block_id, scope, is, dictionaries), BlockKind::Expression(e) => e.nl(policy_path, block_id, scope, is), BlockKind::Match(m) => m.nl(policy_path, block_id, scope, is), } diff --git a/core/engine/src/policy/db.rs b/core/engine/src/policy/db.rs index 746674c3..1d87ac6c 100644 --- a/core/engine/src/policy/db.rs +++ b/core/engine/src/policy/db.rs @@ -147,6 +147,15 @@ pub struct Unit { pub dictionary_blocks: Vec, } +impl Unit { + pub(crate) fn dictionary_types(&self) -> HashMap, VariableType> { + self.dictionaries + .iter() + .map(|(name, dict)| (name.clone(), dict.enum_type())) + .collect() + } +} + pub struct DictionaryUnitEntry { pub policy_path: Arc, pub block_id: Arc, @@ -319,6 +328,7 @@ impl Db { &snap.rule_by_ref, &unit.members, &self.intellisense, + Rc::new(unit.dictionary_types()), )) }) .clone() diff --git a/core/engine/src/policy/editor.rs b/core/engine/src/policy/editor.rs index ae55508f..d107e0ef 100644 --- a/core/engine/src/policy/editor.rs +++ b/core/engine/src/policy/editor.rs @@ -44,30 +44,35 @@ impl Db { return Vec::new(); }; let scope = self.enriched(policy).scope.shallow_clone(); + let dictionaries = self.unit(policy).dictionary_types(); 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)); + out.extend(rule.nl(&policy_arc, &scope, &mut is, &dictionaries)); } is.set_nl_labels(None); out } pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option { - let (kind, scope) = self.nl_scope(cursor)?; + let (kind, scope, expected) = 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 is = intellisense.borrow_mut(); is.set_nl_labels(labels); - let mut result = is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope); + let mut result = + is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope, expected.as_ref()); if unary { let subject = scope.get("$"); result.subject_options = is.nl_subject_options(&subject); result.subject_type = Some(subject); + } else if let Some(expected) = &expected { + result.subject_options = is.nl_subject_options(expected); + result.subject_type = Some(expected.shallow_clone()); } is.set_nl_labels(None); Some(result) @@ -101,15 +106,19 @@ impl Db { })) } - fn nl_scope(&self, cursor: &Cursor) -> Option<(ExpressionKind, VariableType)> { + fn nl_scope( + &self, + cursor: &Cursor, + ) -> Option<(ExpressionKind, VariableType, Option)> { let block = self.block_ir(&BlockRef { policy_path: cursor.policy_path.clone(), block_id: cursor.block_id.clone(), })?; let scope = self.enriched(&cursor.policy_path).scope.shallow_clone(); + let dictionaries = self.unit(&cursor.policy_path).dictionary_types(); let intellisense = self.intellisense(); let mut is = intellisense.borrow_mut(); - Some(block.nl_scope(cursor, scope, &mut is)) + Some(block.nl_scope(cursor, scope, &mut is, &dictionaries)) } pub fn prepare_rename(&self, cursor: &Cursor) -> Option { diff --git a/core/engine/src/policy/queries/dependency.rs b/core/engine/src/policy/queries/dependency.rs index 15579505..d84ca198 100644 --- a/core/engine/src/policy/queries/dependency.rs +++ b/core/engine/src/policy/queries/dependency.rs @@ -1,3 +1,4 @@ +use std::rc::Rc; use std::sync::Arc; use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; @@ -6,8 +7,8 @@ use petgraph::prelude::{NodeIndex, StableDiGraph}; use zen_expression::variable::VariableType; use crate::policy::blocks::{ - AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedIntelliSense, - WriteTarget, + AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDictionaryTypes, + SharedIntelliSense, WriteTarget, }; use crate::policy::db::{AnalysisPass, PolicyDerivedCache, Snapshot}; use crate::policy::ir::{DataModelIr, ParsedPolicy, PropertyPath}; @@ -298,6 +299,7 @@ impl Snapshot { rule_scope: VariableType, pass: AnalysisPass, intellisense: &SharedIntelliSense, + dictionary_types: &SharedDictionaryTypes, ) -> AnalysisSummary { let mut ctx = AnalysisContext::new( rule_scope, @@ -305,6 +307,7 @@ impl Snapshot { rule.id.clone(), intellisense.clone(), pass, + dictionary_types.clone(), ); rule.kind.analyze(&mut ctx); ctx.finish() @@ -329,6 +332,7 @@ impl Snapshot { rule.check_single_entity_scope(path, classifier, &mut diagnostics); } + let no_dictionaries: SharedDictionaryTypes = Rc::new(ahash::HashMap::default()); let policy_shallow = cache.shallow_or_compute(path, p, || { p.policy .rules() @@ -339,6 +343,7 @@ impl Snapshot { base_scope.shallow_clone(), AnalysisPass::Shallow, intellisense, + &no_dictionaries, ); RuleShallowAnalysis { policy_path: path.clone(), @@ -531,6 +536,7 @@ impl Snapshot { rule_by_ref: &HashMap>, members: &HashSet>, intellisense: &SharedIntelliSense, + dictionary_types: SharedDictionaryTypes, ) -> EnrichedState { let scope = base_scope.shallow_clone(); let mut per_rule: Vec = Vec::new(); @@ -576,6 +582,7 @@ impl Snapshot { scope.shallow_clone(), AnalysisPass::Enriched, intellisense, + &dictionary_types, ); if splice { diff --git a/core/engine/src/policy/types/nl.rs b/core/engine/src/policy/types/nl.rs index 4c0195d3..b86ef05e 100644 --- a/core/engine/src/policy/types/nl.rs +++ b/core/engine/src/policy/types/nl.rs @@ -25,13 +25,30 @@ impl NlExpression { kind: ExpressionKind, source: &str, scope: &VariableType, + ) -> Self { + Self::project_expected(is, policy_path, block_id, target, kind, source, scope, None) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn project_expected( + is: &mut IntelliSense, + policy_path: &Arc, + block_id: &Arc, + target: CursorTarget, + kind: ExpressionKind, + source: &str, + scope: &VariableType, + expected: Option<&VariableType>, ) -> Self { let unary = matches!(kind, ExpressionKind::Unary); - let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope); + let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope, expected); if unary { let subject = scope.get("$"); result.subject_options = is.nl_subject_options(&subject); result.subject_type = Some(subject); + } else if let Some(expected) = expected { + result.subject_options = is.nl_subject_options(expected); + result.subject_type = Some(expected.shallow_clone()); } Self { policy_path: policy_path.clone(), diff --git a/core/engine/tests/policy_output_types.rs b/core/engine/tests/policy_output_types.rs new file mode 100644 index 00000000..d16aebb6 --- /dev/null +++ b/core/engine/tests/policy_output_types.rs @@ -0,0 +1,254 @@ +use serde_json::json; +use zen_engine::policy::{Cursor, CursorTarget, NlExpression, PolicyWorkspace, ScopeRequest}; +use zen_expression::nl::{EditHint, NlTokenKind}; + +fn tier_dictionary() -> serde_json::Value { + json!({ + "id": "dict1", + "type": "dictionary", + "props": { "data": { + "name": "customerTier", + "entries": [ + { "id": "e1", "value": "VIP", "label": "Very important" }, + { "id": "e2", "value": "STD", "label": "Standard" } + ] + }} + }) +} + +fn table_with_output(column_type: &str, cells: &[&str]) -> serde_json::Value { + let rules: Vec = cells + .iter() + .enumerate() + .map(|(i, cell)| json!({ "_id": format!("row{i}"), "in1": if i == 0 { "" } else { "> 10" }, "out1": cell })) + .collect(); + json!({ + "id": "dt1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "in1", "name": "Age", "field": "customer.age" } ], + "outputs": [ { "id": "out1", "name": "Tier", "field": "customer.tier", "type": column_type } ], + "rules": rules + }} + }) +} + +fn workspace_with(blocks: Vec) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "main", + serde_json::from_value(json!({ "blocks": blocks })).unwrap(), + ); + ws +} + +fn cell_diagnostics(ws: &PolicyWorkspace) -> Vec { + ws.diagnostics("main") + .iter() + .map(|d| format!("{d:?}")) + .collect() +} + +#[test] +fn number_column_rejects_string_cells() { + let ws = workspace_with(vec![table_with_output("number", &["42", "'high'"])]); + let diagnostics = cell_diagnostics(&ws); + assert!( + diagnostics + .iter() + .any(|d| d.contains("must be `number`") && d.contains("row1")), + "got: {diagnostics:?}" + ); + assert!( + !diagnostics.iter().any(|d| d.contains("row0")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn typed_column_accepts_matching_cells() { + let ws = workspace_with(vec![table_with_output( + "number", + &["42", "customer.age * 2"], + )]); + let diagnostics = cell_diagnostics(&ws); + assert!( + !diagnostics.iter().any(|d| d.contains("TypeMismatch")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn dictionary_column_checks_membership_of_literals() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier", &["'VIP'", "'GOLD'"]), + ]); + let diagnostics = cell_diagnostics(&ws); + assert!( + diagnostics + .iter() + .any(|d| d.contains("must be `customerTier`") && d.contains("row1")), + "got: {diagnostics:?}" + ); + assert!( + !diagnostics.iter().any(|d| d.contains("row0")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn dictionary_array_column_accepts_and_checks_lists() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier[]", &["['VIP', 'STD']", "['VIP', 'GOLD']"]), + ]); + let diagnostics = cell_diagnostics(&ws); + assert!( + diagnostics + .iter() + .any(|d| d.contains("must be `customerTier[]`") && d.contains("row1")), + "got: {diagnostics:?}" + ); + assert!( + !diagnostics.iter().any(|d| d.contains("row0")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn unknown_dictionary_type_is_diagnosed_on_head() { + let ws = workspace_with(vec![table_with_output("goldTier", &["'VIP'"])]); + let diagnostics = cell_diagnostics(&ws); + assert!( + diagnostics + .iter() + .any(|d| d.contains("unknown output type 'goldTier'")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn malformed_type_annotation_is_diagnosed() { + let ws = workspace_with(vec![table_with_output("customer tier", &["'VIP'"])]); + let diagnostics = cell_diagnostics(&ws); + assert!( + diagnostics + .iter() + .any(|d| d.contains("invalid output type 'customer tier'")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn declared_type_narrows_output_schema() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier", &["'VIP'", "'STD'"]), + ]); + let outputs = ws.outputs(&ScopeRequest { + policy_path: "main".into(), + goals: Vec::new(), + }); + let customer = outputs + .iter() + .find(|o| o.path.as_ref() == "customer") + .unwrap_or_else(|| panic!("output present, got: {outputs:?}")); + let printed = format!("{:?}", customer.resolved_type); + assert!( + printed.contains("Enum(Some(\"customerTier\"), [\"VIP\", \"STD\"])"), + "expected narrowed enum type, got: {printed}" + ); +} + +fn output_cell<'a>(results: &'a [NlExpression], row: &str) -> &'a NlExpression { + results + .iter() + .find(|e| { + matches!( + &e.target, + CursorTarget::DecisionTableCell { row: r, col } + if r.as_ref() == row && col.as_ref() == "out1" + ) + }) + .expect("output cell projected") +} + +#[test] +fn nl_output_cell_gets_enum_select_with_labels() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier", &["'VIP'"]), + ]); + let results = ws.nl("main"); + let cell = output_cell(&results, "row0"); + + let token = &cell.result.tokens[0]; + assert!(matches!(token.token, NlTokenKind::Str { .. })); + let EditHint::Select { options } = token.hint.clone().expect("select hint") else { + panic!("expected select hint, got {:?}", token.hint); + }; + let options = &cell.result.enums[options as usize]; + assert_eq!(options[0].label, "Very important"); + assert_eq!(options[0].source.as_deref(), Some("\"VIP\"")); + + let subject_options = cell.result.subject_options.as_ref().expect("options"); + assert_eq!(subject_options.len(), 2); +} + +#[test] +fn nl_array_output_cell_gets_multiselect() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier[]", &["['VIP', 'STD']"]), + ]); + let results = ws.nl("main"); + let cell = output_cell(&results, "row0"); + + let token = &cell.result.tokens[0]; + assert!(matches!(token.token, NlTokenKind::EnumList { .. })); + assert!(matches!(token.hint, Some(EditHint::MultiSelect { .. }))); +} + +#[test] +fn nl_tokenize_live_output_cell_uses_declared_type() { + let ws = workspace_with(vec![ + tier_dictionary(), + table_with_output("customerTier", &["'VIP'"]), + ]); + let result = ws + .nl_tokenize( + &Cursor { + policy_path: "main".into(), + block_id: "dt1".into(), + pos: 0, + target: CursorTarget::DecisionTableCell { + row: "row0".into(), + col: "out1".into(), + }, + }, + "'STD'", + ) + .expect("tokenized"); + + let token = &result.tokens[0]; + assert!(matches!(token.hint, Some(EditHint::Select { .. }))); + let subject_options = result.subject_options.as_ref().expect("options"); + assert_eq!(subject_options[1].label, "Standard"); +} + +#[test] +fn untyped_columns_keep_inferred_behavior() { + let ws = workspace_with(vec![table_with_output("", &["'a'", "'b'"])]); + let diagnostics = cell_diagnostics(&ws); + assert!( + !diagnostics.iter().any(|d| d.contains("TypeMismatch")), + "got: {diagnostics:?}" + ); + + let results = ws.nl("main"); + let cell = output_cell(&results, "row0"); + assert!(cell.result.tokens[0].hint.is_none()); + assert!(cell.result.subject_options.is_none()); +} diff --git a/core/expression/src/intellisense/mod.rs b/core/expression/src/intellisense/mod.rs index 7df4e5fa..a951673e 100644 --- a/core/expression/src/intellisense/mod.rs +++ b/core/expression/src/intellisense/mod.rs @@ -208,12 +208,23 @@ impl IntelliSense { } else { root_type.shallow_clone() }; - let mut result = - self.nl_tokenize_scoped(&request.id, &request.expression, request.unary, &scope); + let expected = (!request.unary) + .then_some(request.subject_type.as_ref()) + .flatten(); + let mut result = self.nl_tokenize_scoped( + &request.id, + &request.expression, + request.unary, + &scope, + expected, + ); if request.unary { let subject = scope.get("$"); result.subject_options = self.nl_subject_options(&subject); result.subject_type = Some(subject); + } else if let Some(expected) = expected { + result.subject_options = self.nl_subject_options(expected); + result.subject_type = Some(expected.shallow_clone()); } result } @@ -228,6 +239,7 @@ impl IntelliSense { source: &str, unary: bool, scope_type: &VariableType, + expected: Option<&VariableType>, ) -> NlResult { let mut result = NlResult { id: id.to_string(), @@ -286,7 +298,8 @@ impl IntelliSense { collect_type_diagnostics(ast, &type_data, &metadata, &mut result.diagnostics); let (tokens, enums) = - Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()).run(ast); + Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()) + .run(ast, expected.map(|e| e.shallow_clone())); result.tokens = tokens; result.enums = enums; result diff --git a/core/expression/src/nl/project.rs b/core/expression/src/nl/project.rs index 4531b106..cbf114e0 100644 --- a/core/expression/src/nl/project.rs +++ b/core/expression/src/nl/project.rs @@ -50,8 +50,12 @@ impl<'a> Projector<'a> { } } - pub(crate) fn run(mut self, root: &Node) -> (Vec, Vec>) { - self.project(root, None); + pub(crate) fn run( + mut self, + root: &Node, + expected: Option, + ) -> (Vec, Vec>) { + self.project(root, expected); (self.out, self.enums) } diff --git a/core/types/src/decision/mod.rs b/core/types/src/decision/mod.rs index bf1c75ca..163f4def 100644 --- a/core/types/src/decision/mod.rs +++ b/core/types/src/decision/mod.rs @@ -141,6 +141,13 @@ pub struct DecisionTableOutputField { #[serde(default = "empty_arc_str")] pub name: Arc, pub field: Arc, + #[serde( + rename = "type", + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "empty_string_is_none" + )] + pub column_type: Option>, } fn empty_arc_str() -> Arc {