Files
zen/bindings/nodejs/src/expression.rs
T
stefan-gorules ffdfd807d1 feat: implement slots instead of natural language (#527)
* feat: implement slots instead of natural language

Replace the natural-language projection (`nl`) with engine-owned slot
completion: for a cursor position the engine classifies the slot being
edited (operand, operator, literal, member access, ...) and returns typed
options, literal facts and the expected type, for policies and graphs.

- zen-expression: new `slot` module (classification, literals, operators)
  and a lenient lexer mode for incomplete input; `nl` removed
- Intellisense: typed diagnostic codes with arguments, richer inspect for
  hover, dedicated parser error variants (messages unchanged)
- Engine: `Workspace::cursor_scope`, `slot`, `facts`; `rename_from` and
  `references_from`; editor spans in UTF-16 units; renames reach imported
  policies, `$nodes` reads, child graphs and output column heads; no `$root`
  completion in policies
- Node bindings: `slot`, `facts`, `cursorScope`, `slotBatch` replace
  `nl`, `nlTokenize`, `nlEncodeString`, `nlTokenizeBatch`

No change to expression evaluation: the VM, compiler, functions and the
default lexer path are untouched, and parser error messages keep their text.

* fix: harden slot classification, scopes and graph expectations

- restore master execution order; slot scopes hide writes of the edited
  block and its dependents instead of relying on block ranking
- token-start editing gated by slot state; operator, list and operand
  spans no longer splice into neighbouring tokens
- interval-aware bracket pairing for half-open and reversed intervals
- lenient lexer recovers from unknown characters (slots only)
- graph expected type follows switch and pass-through nodes and merges
  compatible output schemas
- sibling inference caps distinct values and analyses lazily
- deterministic overload return type display

* fix: order policy blocks by entity reads through closures and relationships

- record reads for `#.field` in closures so entity fields read through
  lists become dependencies
- link reads through derived lists (filter/map results) to the entity
  fields they carry and order the list after those fields
- evaluate entity blocks on single relationships, and demand entity
  paths for plain reads through relationships
- slot scopes keep the top-level variable when hiding written fields
2026-09-28 21:47:40 +02:00

122 lines
4.0 KiB
Rust

use napi::anyhow::anyhow;
use napi_derive::napi;
use serde_json::Value;
#[napi]
pub fn evaluate_expression_sync(expression: String, context: Option<Value>) -> napi::Result<Value> {
let ctx = zen_expression::Variable::try_from_value(context.unwrap_or(Value::Null))
.map_err(|e| anyhow!(e))?;
Ok(
zen_expression::evaluate_expression(expression.as_str(), ctx)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?
.to_value(),
)
}
#[allow(dead_code)]
#[napi]
pub fn evaluate_unary_expression_sync(expression: String, context: Value) -> napi::Result<bool> {
let context = zen_expression::Variable::try_from_value(context).map_err(|e| anyhow!(e))?;
Ok(
zen_expression::evaluate_unary_expression(expression.as_str(), context)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?,
)
}
#[allow(dead_code)]
#[napi]
pub fn render_template_sync(template: String, context: Value) -> napi::Result<Value> {
let context = zen_expression::Variable::try_from_value(context).map_err(|e| anyhow!(e))?;
Ok(zen_tmpl::render(template.as_str(), context)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?
.to_value())
}
#[allow(dead_code)]
#[napi]
pub async fn evaluate_expression(
expression: String,
context: Option<Value>,
) -> napi::Result<Value> {
napi::tokio::spawn(async move { evaluate_expression_sync(expression, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
#[allow(dead_code)]
#[napi]
pub async fn evaluate_unary_expression(expression: String, context: Value) -> napi::Result<bool> {
napi::tokio::spawn(async move { evaluate_unary_expression_sync(expression, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
#[allow(dead_code)]
#[napi]
pub async fn render_template(template: String, context: Value) -> napi::Result<Value> {
napi::tokio::spawn(async move { render_template_sync(template, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
pub(crate) fn json_to_variable_type(value: &Value) -> zen_expression::variable::VariableType {
use std::rc::Rc;
use zen_expression::variable::VariableType as VT;
let Some(tag) = value.get("type").and_then(Value::as_str) else {
return VT::Any;
};
match tag {
"any" => VT::Any,
"null" => VT::Null,
"bool" => VT::Bool,
"string" => VT::String,
"number" => VT::Number,
"date" => VT::Date,
"interval" => VT::Interval,
"const" => value
.get("value")
.and_then(Value::as_str)
.map(|s| VT::Const(Rc::from(s)))
.unwrap_or(VT::Any),
"enum" => {
let name = value.get("name").and_then(Value::as_str).map(Rc::from);
let values = value
.get("values")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).map(Rc::from).collect())
.unwrap_or_default();
VT::Enum(name, values)
}
"array" => {
let items = value
.get("items")
.map(json_to_variable_type)
.unwrap_or(VT::Any);
VT::Array(Rc::new(items))
}
"object" => {
let object = VT::empty_object();
if let (VT::Object(map), Some(fields)) =
(&object, value.get("fields").and_then(Value::as_object))
{
for (key, field) in fields {
map.borrow_mut()
.insert(Rc::from(key.as_str()), json_to_variable_type(field));
}
}
object
}
"nullable" => {
let inner = value
.get("inner")
.map(json_to_variable_type)
.unwrap_or(VT::Any);
VT::Nullable(Rc::new(inner))
}
_ => VT::Any,
}
}