fix: nullable keys after switch joins, no default-to-0 fix where errors skip, fallible outputs don't shadow, integer and aliased fields in missing cases

- keys set by only some switch branches are nullable where branches join, in $nodes and in graph output
- the (x ?? 0) fix is not offered in graph table cells and switch conditions, where an error means no match
- a graph first-hit row whose output can fail no longer makes later rows unreachable
- integer schema fields drop gaps without integers; min/max are read through nullable anyOf
- columns over the same field path ((x), x['y']) share one dimension; derived columns overlapping another column are left out of gap checks
This commit is contained in:
Stefan
2026-10-02 09:05:00 +02:00
parent 8578006ea3
commit 1224ea4c75
12 changed files with 561 additions and 40 deletions
+10 -2
View File
@@ -11,6 +11,12 @@ use crate::workspace::types::{Diagnostic, DiagnosticCode, Span};
pub(crate) struct NullableOperand;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum OnError {
Raise,
Skip,
}
#[derive(Default)]
struct Fallback {
operands: Option<(Span, Span)>,
@@ -44,8 +50,9 @@ impl NullableOperand {
is: &mut IntelliSense,
source: &str,
unary: bool,
on_error: OnError,
) {
Self::default_operands(diagnostics, is, source, unary);
Self::default_operands(diagnostics, is, source, unary, on_error);
Self::fallbacks(diagnostics, is, source, unary);
}
@@ -54,6 +61,7 @@ impl NullableOperand {
is: &mut IntelliSense,
source: &str,
unary: bool,
on_error: OnError,
) {
let requests: Vec<(usize, Span, String, bool)> = diagnostics
.iter()
@@ -88,7 +96,7 @@ impl NullableOperand {
"/" | "%" => found.left,
_ => false,
};
if !defaultable {
if !defaultable || on_error == OnError::Skip {
continue;
}
let Some(operand) = AstOps::text(source, found.operand) else {
+2
View File
@@ -77,6 +77,7 @@ impl VerifyTable<'_> {
col.unary.hash(&mut state);
col.analyzable.hash(&mut state);
col.dated.hash(&mut state);
col.integer.hash(&mut state);
col.input.hash(&mut state);
col.field.hash(&mut state);
col.path.hash(&mut state);
@@ -91,6 +92,7 @@ impl VerifyTable<'_> {
col.label.hash(&mut state);
col.values.hash(&mut state);
}
self.fallible.hash(&mut state);
self.rules.len().hash(&mut state);
for rule in self.rules {
let (low, high) = rule.iter().fold((0u64, 0u64), |(low, high), entry| {
+94 -11
View File
@@ -1,7 +1,11 @@
use std::sync::Arc;
use std::rc::Rc;
use ahash::HashMap;
use serde_json::{Map, Value};
use zen_expression::intellisense::values::cell::FieldPath;
use zen_expression::intellisense::{IntelliSense, ReadDependency};
use super::cell::CellConstraint;
use super::partition::Partition;
@@ -19,19 +23,58 @@ struct Dimension {
label: Arc<str>,
prefer: Option<ValueKind>,
dated: bool,
integer: bool,
input: bool,
}
struct Field {
path: Option<Vec<Rc<str>>>,
reads: Vec<Vec<Rc<str>>>,
}
impl Field {
fn of(is: &mut IntelliSense, source: &str) -> Self {
match FieldPath::of(is, source) {
Some(path) => Self {
reads: vec![path.clone()],
path: Some(path),
},
None => Self {
path: None,
reads: is
.reads(source)
.into_iter()
.filter_map(|read| match read {
ReadDependency::Direct { path, .. } => Some(path),
ReadDependency::Iteration { collection, .. } => Some(collection),
_ => None,
})
.collect(),
},
}
}
fn overlaps(&self, other: &Field) -> bool {
self.reads.iter().any(|a| {
other
.reads
.iter()
.any(|b| a.iter().zip(b).all(|(x, y)| x == y))
})
}
}
impl VerifyTable<'_> {
pub(super) fn missing(
&self,
is: &mut IntelliSense,
cells: &[Vec<CellConstraint>],
satisfiable: &[bool],
) -> Option<Option<Finding>> {
if self.rules.is_empty() {
return Some(None);
}
let dims = self.dimensions(cells);
let dims = self.dimensions(is, cells);
if dims.is_empty() || dims.iter().any(|d| d.domain.is_empty()) {
return Some(None);
}
@@ -61,7 +104,21 @@ impl VerifyTable<'_> {
dims.iter().map(|d| d.domain.clone()).collect(),
(0..cuts.len()).collect(),
)?;
let remaining = gaps.out;
let remaining: Vec<Region> = gaps
.out
.into_iter()
.filter_map(|mut fragment| {
for (dim, set) in dims.iter().zip(fragment.iter_mut()) {
if dim.integer {
set.numbers = set.numbers.integral();
}
}
fragment
.iter()
.all(|set| !set.is_empty())
.then_some(fragment)
})
.collect();
if remaining.is_empty() {
return Some(None);
}
@@ -74,15 +131,32 @@ impl VerifyTable<'_> {
Some(Some(Finding::MissingCases { cases, total }))
}
fn dimensions(&self, cells: &[Vec<CellConstraint>]) -> Vec<Dimension> {
let mut dims: Vec<(Arc<str>, Dimension)> = Vec::new();
fn dimensions(&self, is: &mut IntelliSense, cells: &[Vec<CellConstraint>]) -> Vec<Dimension> {
let fields: Vec<Option<Field>> = self
.inputs
.iter()
.map(|col| {
col.field
.as_ref()
.filter(|_| col.analyzable)
.map(|field| Field::of(is, field))
})
.collect();
let mut dims: Vec<(Vec<Rc<str>>, Dimension)> = Vec::new();
for (idx, col) in self.inputs.iter().enumerate() {
if !col.analyzable {
continue;
}
let Some(field) = col.field.clone() else {
let (Some(field), Some(source)) = (&fields[idx], &col.field) else {
continue;
};
let key = match &field.path {
Some(path) => path.clone(),
None if fields.iter().enumerate().any(|(other, f)| {
other != idx && f.as_ref().is_some_and(|f| f.overlaps(field))
}) =>
{
continue
}
None => vec![Rc::from(source.as_ref())],
};
let Some(domain) = col
.domain
.clone()
@@ -90,20 +164,29 @@ impl VerifyTable<'_> {
else {
continue;
};
match dims.iter_mut().find(|(key, _)| *key == field) {
match dims.iter_mut().find(|(existing, _)| *existing == key) {
Some((_, dim)) => {
dim.columns.push(idx);
dim.domain = dim.domain.intersect(&domain);
dim.integer |= col.integer;
dim.path = dim.path.clone().or_else(|| col.path.clone());
}
None => dims.push((
field,
key,
Dimension {
columns: vec![idx],
domain,
path: col.path.clone(),
path: col.path.clone().or_else(|| {
field
.path
.as_ref()
.filter(|path| path.iter().all(|segment| !segment.contains('.')))
.map(|path| Arc::from(path.join(".")))
}),
label: col.label.clone(),
prefer: col.prefer,
dated: col.dated,
integer: col.integer,
input: col.input,
},
)),
+1
View File
@@ -71,6 +71,7 @@ impl TableColumn {
unary: field.is_some(),
analyzable: field.is_some(),
dated,
integer: false,
input: true,
field: field.map(|f| Arc::from(f.trim())),
path: field.filter(|f| Self::is_plain_path(f)).cloned(),
+8 -2
View File
@@ -28,6 +28,7 @@ pub(crate) struct VerifyInput {
pub(crate) unary: bool,
pub(crate) analyzable: bool,
pub(crate) dated: bool,
pub(crate) integer: bool,
pub(crate) input: bool,
pub(crate) field: Option<Arc<str>>,
pub(crate) path: Option<Arc<str>>,
@@ -49,6 +50,7 @@ pub(crate) struct VerifyTable<'a> {
pub(crate) inputs: Vec<VerifyInput>,
pub(crate) outputs: Vec<VerifyOutput>,
pub(crate) rules: &'a [HashMap<Arc<str>, Arc<str>>],
pub(crate) fallible: Vec<bool>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -159,7 +161,7 @@ impl VerifyTable<'_> {
let mut seen: HashMap<(Vec<CellConstraint>, Vec<String>), usize> = HashMap::default();
for row in 0..self.rules.len() {
if let Some(index) = index.as_mut().filter(|_| row > 0) {
if satisfiable[row - 1] && !reported[row - 1] {
if satisfiable[row - 1] && !reported[row - 1] && !self.can_fail(row - 1) {
index.insert(&cells[row - 1], row - 1);
}
}
@@ -212,7 +214,7 @@ impl VerifyTable<'_> {
}
let mut gaps_incomplete = !coverage;
if coverage {
match self.missing(&cells, &satisfiable) {
match self.missing(is, &cells, &satisfiable) {
Some(Some(missing)) => findings.push(missing),
Some(None) => {}
None => gaps_incomplete = true,
@@ -273,6 +275,10 @@ impl VerifyTable<'_> {
findings
}
fn can_fail(&self, row: usize) -> bool {
self.fallible.get(row).copied().unwrap_or(false)
}
fn contributes_collect(&self, row: usize) -> bool {
self.outputs
.iter()
+2 -1
View File
@@ -8,7 +8,7 @@ use zen_expression::{Isolate, IsolateError};
use super::property_read::ReadFlattener;
use super::type_check::TypeCheck;
use crate::analysis::nullable::NullableOperand;
use crate::analysis::nullable::{NullableOperand, OnError};
use crate::analysis::table::{HitMode, VerifyInput, VerifyOutput, VerifyTable};
use crate::policy::ir::PropertyPath;
use crate::policy::queries::dependency::{DataModelPaths, PathPrefix};
@@ -502,6 +502,7 @@ impl AnalysisContext {
&mut self.intellisense.borrow_mut(),
source,
matches!(kind, ExpressionKind::Unary),
OnError::Raise,
);
}
}
@@ -477,6 +477,7 @@ impl DecisionTableIr {
})
.collect(),
rules: &self.rules,
fallible: Vec::new(),
};
cx.defer_table_check(table);
}
@@ -715,6 +716,7 @@ impl DecisionTableIr {
inputs: check.inputs.clone(),
outputs: check.outputs.clone(),
rules: &self.rules,
fallible: Vec::new(),
};
table.diagnostics(
is,
+275 -24
View File
@@ -13,7 +13,7 @@ use zen_types::decision::{
use zen_expression::intellisense::ArmTest;
use crate::analysis::nullable::NullableOperand;
use crate::analysis::nullable::{NullableOperand, OnError};
use crate::analysis::table::{
Bound, HitMode, Interval, NumberSet, PathConstraints, TableColumn, VerifyTable,
};
@@ -118,6 +118,30 @@ struct GraphTopology {
incoming: IncomingEdges,
outgoing: Vec<Vec<usize>>,
order: Option<Vec<usize>>,
may_skip: Vec<bool>,
guaranteed: Vec<HashSet<usize>>,
}
impl GraphTopology {
fn skips(&self, idx: usize) -> bool {
self.may_skip.get(idx).copied().unwrap_or(false)
}
fn runs_with(&self, idx: usize, current: usize) -> bool {
!self.skips(idx)
|| self
.guaranteed
.get(current)
.is_some_and(|g| g.contains(&idx))
}
fn certain_edge(&self, content: &GraphContent, current: usize, edge: usize) -> bool {
let incoming = &self.incoming[current];
let (pred, handle) = &incoming[edge];
incoming.len() == 1
|| (self.runs_with(*pred, current)
&& !GraphAnalyzer::skippable(content, *pred, handle.as_deref()))
}
}
impl<'a> GraphAnalyzer<'a> {
@@ -175,6 +199,7 @@ impl<'a> GraphAnalyzer<'a> {
Self::merged_input(self.content, &topology, &nodes, idx);
self.nodes_scope = Self::nodes_scope_of(
self.content,
&topology,
idx,
&ancestor_set,
&descendants[idx],
@@ -289,14 +314,68 @@ impl<'a> GraphAnalyzer<'a> {
));
}
let (may_skip, guaranteed) = match &order {
Some(order) => Self::execution(content, &incoming, order),
None => (Vec::new(), Vec::new()),
};
GraphTopology {
node_index,
incoming,
outgoing,
order,
may_skip,
guaranteed,
}
}
fn execution(
content: &GraphContent,
incoming: &IncomingEdges,
order: &[usize],
) -> (Vec<bool>, Vec<HashSet<usize>>) {
let mut may_skip = vec![false; incoming.len()];
let mut guaranteed: Vec<HashSet<usize>> = vec![HashSet::default(); incoming.len()];
for &idx in order {
let edges = &incoming[idx];
may_skip[idx] = !edges.is_empty()
&& edges.iter().all(|(pred, handle)| {
may_skip[*pred] || Self::skippable(content, *pred, handle.as_deref())
});
let mut sets = edges.iter().map(|(pred, _)| &guaranteed[*pred]);
let mut runs: HashSet<usize> = match sets.next() {
Some(first) => sets.fold(first.clone(), |acc, set| {
acc.intersection(set).copied().collect()
}),
None => HashSet::default(),
};
runs.insert(idx);
guaranteed[idx] = runs;
}
(may_skip, guaranteed)
}
fn skippable(content: &GraphContent, source: usize, handle: Option<&str>) -> bool {
let DecisionNodeKind::SwitchNode { content: switch } = &content.nodes[source].kind else {
return false;
};
let Some(handle) = handle else {
return true;
};
let Some(position) = switch
.statements
.iter()
.position(|statement| statement.id.as_ref() == handle)
else {
return true;
};
let always = switch.statements[position].condition.trim().is_empty()
&& match switch.hit_policy {
SwitchStatementHitPolicy::Collect => true,
SwitchStatementHitPolicy::First => position == 0,
};
!always
}
fn topological_order(incoming: &IncomingEdges, outgoing: &[Vec<usize>]) -> Option<Vec<usize>> {
let mut indegree: Vec<usize> = incoming.iter().map(Vec::len).collect();
let mut queue: VecDeque<usize> = indegree
@@ -334,6 +413,7 @@ impl<'a> GraphAnalyzer<'a> {
fn nodes_scope_of(
content: &GraphContent,
topology: &GraphTopology,
current: usize,
ancestor_set: &HashSet<usize>,
descendant_set: &HashSet<usize>,
@@ -350,7 +430,10 @@ impl<'a> GraphAnalyzer<'a> {
}
let resolved = if ancestor_set.contains(&idx) {
match nodes.get(&node.id) {
Some(analysis) => analysis.output.shallow_clone(),
Some(analysis) if topology.runs_with(idx, current) => {
analysis.output.shallow_clone()
}
Some(analysis) => super::wrap_optional(analysis.output.shallow_clone()),
None => VariableType::Any,
}
} else {
@@ -375,7 +458,10 @@ impl<'a> GraphAnalyzer<'a> {
let mut unchecked = false;
let mut open = false;
let mut merged: Option<VariableType> = None;
for (pred, handle) in &topology.incoming[idx] {
let mut certain: Vec<VariableType> = Vec::new();
let mut every: Vec<VariableType> = Vec::new();
let mut partial = false;
for (edge, (pred, handle)) in topology.incoming[idx].iter().enumerate() {
let Some(analysis) = nodes.get(&content.nodes[*pred].id) else {
continue;
};
@@ -385,16 +471,75 @@ impl<'a> GraphAnalyzer<'a> {
.as_ref()
.and_then(|h| analysis.branch_outputs.get(h.as_ref()))
.unwrap_or(&analysis.output);
match topology.certain_edge(content, idx, edge) {
true => certain.push(branch.shallow_clone()),
false => partial = true,
}
every.push(branch.shallow_clone());
merged = Some(match merged {
None => branch.shallow_clone(),
Some(acc) => acc.merge(branch),
});
}
(
merged.unwrap_or_else(VariableType::empty_object),
unchecked,
open,
)
let merged = merged.unwrap_or_else(VariableType::empty_object);
let merged = match partial {
true => Self::present_in(
&merged,
&certain.iter().collect::<Vec<_>>(),
Some(&every.iter().collect::<Vec<_>>()),
),
false => merged,
};
(merged, unchecked, open)
}
fn present_in(
merged: &VariableType,
certain: &[&VariableType],
every: Option<&[&VariableType]>,
) -> VariableType {
let VariableType::Object(fields) = merged else {
return merged.shallow_clone();
};
let opaque = |t: &&VariableType| !matches!(t, VariableType::Object(_));
if certain.iter().any(opaque) || every.is_some_and(|every| every.iter().any(opaque)) {
return merged.shallow_clone();
}
let at = |types: &[&VariableType], key: &Rc<str>| -> Vec<VariableType> {
types
.iter()
.filter_map(|t| match t {
VariableType::Object(object) => {
object.borrow().get(key).map(VariableType::shallow_clone)
}
_ => None,
})
.collect()
};
let result = VariableType::empty_object();
if let VariableType::Object(out) = &result {
let mut out = out.borrow_mut();
for (key, value) in fields.borrow().iter() {
let present = at(certain, key);
let everywhere = every
.map(|every| (at(every, key), every.len()))
.filter(|(values, len)| values.len() == *len)
.map(|(values, _)| values);
let value = match (present.is_empty(), &everywhere) {
(true, None) => super::wrap_optional(value.shallow_clone()),
_ => Self::present_in(
value,
&present.iter().collect::<Vec<_>>(),
everywhere
.as_ref()
.map(|values| values.iter().collect::<Vec<_>>())
.as_deref(),
),
};
out.insert(key.clone(), value);
}
}
result
}
fn reachable_from_inputs(
@@ -428,20 +573,32 @@ impl<'a> GraphAnalyzer<'a> {
nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
) -> VariableType {
let reachable = Self::reachable_from_inputs(content, topology);
let mut terminals: Vec<&GraphNodeAnalysis> = content
let terminals: Vec<(usize, &GraphNodeAnalysis)> = content
.nodes
.iter()
.enumerate()
.filter(|(idx, _)| topology.outgoing.get(*idx).is_some_and(Vec::is_empty))
.filter(|(idx, _)| reachable.as_ref().is_none_or(|r| r[*idx]))
.filter_map(|(_, node)| nodes.get(&node.id))
.filter_map(|(idx, node)| Some((idx, nodes.get(&node.id)?)))
.collect();
let Some(first) = terminals.pop() else {
let Some(((_, first), rest)) = terminals.split_first() else {
return VariableType::empty_object();
};
terminals
.into_iter()
.fold(first.output.shallow_clone(), |acc, t| acc.merge(&t.output))
let merged = rest
.iter()
.fold(first.output.shallow_clone(), |acc, (_, t)| {
acc.merge(&t.output)
});
let certain: Vec<&VariableType> = terminals
.iter()
.filter(|(idx, _)| !topology.skips(*idx))
.map(|(_, t)| &t.output)
.collect();
let every: Vec<&VariableType> = terminals.iter().map(|(_, t)| &t.output).collect();
match rest.is_empty() || certain.len() == terminals.len() {
true => merged,
false => Self::present_in(&merged, &certain, Some(&every)),
}
}
fn graph_input_type(&self) -> VariableType {
@@ -915,7 +1072,7 @@ impl<'a> GraphAnalyzer<'a> {
continue;
}
let first = self.diagnostics.len();
self.check_expression(
self.check_skipping(
&node.id,
Some(col.id.clone()),
Some(target),
@@ -926,7 +1083,7 @@ impl<'a> GraphAnalyzer<'a> {
checked.insert(key, first..self.diagnostics.len());
}
None => {
let resolved = self.check_expression(
let resolved = self.check_skipping(
&node.id,
Some(col.id.clone()),
Some(target.clone()),
@@ -962,7 +1119,7 @@ impl<'a> GraphAnalyzer<'a> {
}
}
self.verify_decision_table(node, content, &input_field_types);
self.verify_decision_table(node, content, &input_field_types, &base_scope);
for col in content.inputs.iter() {
let Some(field) = &col.field else {
@@ -1050,7 +1207,7 @@ impl<'a> GraphAnalyzer<'a> {
row: Self::row_key(rule, row_idx),
col: col.id.clone(),
};
let resolved = self.check_expression(
let resolved = self.check_skipping(
&node.id,
Some(col.id.clone()),
Some(target.clone()),
@@ -1294,11 +1451,11 @@ impl<'a> GraphAnalyzer<'a> {
out
}
fn schema_number_range(
fn field_schema(
&self,
content: &DecisionTableContent,
field: Option<&str>,
) -> Option<NumberSet> {
) -> Option<&serde_json::Value> {
if !self.preserved_input(content, field) {
return None;
}
@@ -1312,8 +1469,40 @@ impl<'a> GraphAnalyzer<'a> {
_ => None,
})?;
for segment in field.split('.') {
schema = schema.get("properties")?.get(segment)?;
schema = Self::non_null_schema(schema)
.get("properties")?
.get(segment)?;
}
Some(Self::non_null_schema(schema))
}
fn non_null_schema(schema: &serde_json::Value) -> &serde_json::Value {
let branches = ["anyOf", "oneOf"]
.iter()
.find_map(|key| schema.get(*key).and_then(|v| v.as_array()));
let Some(branches) = branches else {
return schema;
};
let mut present = branches
.iter()
.filter(|branch| branch.get("type").and_then(|t| t.as_str()) != Some("null"));
match (present.next(), present.next()) {
(Some(branch), None) => branch,
_ => schema,
}
}
fn schema_integer(schema: &serde_json::Value) -> bool {
match schema.get("type") {
Some(serde_json::Value::String(kind)) => kind == "integer",
Some(serde_json::Value::Array(kinds)) => {
kinds.iter().any(|k| k == "integer") && !kinds.iter().any(|k| k == "number")
}
_ => false,
}
}
fn schema_number_range(schema: &serde_json::Value) -> Option<NumberSet> {
let bound = |key: &str| {
schema
.get(key)
@@ -1341,7 +1530,24 @@ impl<'a> GraphAnalyzer<'a> {
node: &DecisionNode,
content: &DecisionTableContent,
input_field_types: &HashMap<Arc<str>, VariableType>,
scope: &VariableType,
) {
let intellisense = self.db.graph_intellisense();
let fallible = match content.hit_policy {
DecisionTableHitPolicy::First => content
.rules
.iter()
.map(|rule| {
content.outputs.iter().any(|col| {
!col.write_path().0.is_empty()
&& rule.get(&col.id).is_some_and(|cell| {
!cell.is_empty() && intellisense.borrow_mut().can_fail(cell, scope)
})
})
})
.collect(),
DecisionTableHitPolicy::Collect => Vec::new(),
};
let table = VerifyTable {
mode: match content.hit_policy {
DecisionTableHitPolicy::First => HitMode::RowFirst,
@@ -1358,7 +1564,9 @@ impl<'a> GraphAnalyzer<'a> {
input_field_types.get(&col.id),
);
input.input = self.preserved_input(content, col.field.as_deref());
let input = match self.schema_number_range(content, col.field.as_deref()) {
let schema = self.field_schema(content, col.field.as_deref());
input.integer = schema.is_some_and(Self::schema_integer);
let input = match schema.and_then(Self::schema_number_range) {
Some(range) => TableColumn::narrow_numbers(input, range),
None => input,
};
@@ -1389,8 +1597,8 @@ impl<'a> GraphAnalyzer<'a> {
})
.collect(),
rules: &content.rules,
fallible,
};
let intellisense = self.db.graph_intellisense();
let diagnostics = table.diagnostics(
&mut intellisense.borrow_mut(),
|row| Self::row_key(&content.rules[row], row),
@@ -1710,7 +1918,7 @@ impl<'a> GraphAnalyzer<'a> {
let test = if statement.condition.is_empty() {
ArmTest::Default
} else {
let resolved = self.check_expression(
let resolved = self.check_skipping(
&node.id,
Some(statement.id.clone()),
None,
@@ -2072,6 +2280,48 @@ impl<'a> GraphAnalyzer<'a> {
source: &Arc<str>,
kind: ExpressionKind,
scope: &VariableType,
) -> VariableType {
self.check(
node_id,
expression_id,
target,
source,
kind,
scope,
OnError::Raise,
)
}
fn check_skipping(
&mut self,
node_id: &Arc<str>,
expression_id: Option<Arc<str>>,
target: Option<CursorTarget>,
source: &Arc<str>,
kind: ExpressionKind,
scope: &VariableType,
) -> VariableType {
self.check(
node_id,
expression_id,
target,
source,
kind,
scope,
OnError::Skip,
)
}
#[allow(clippy::too_many_arguments)]
fn check(
&mut self,
node_id: &Arc<str>,
expression_id: Option<Arc<str>>,
target: Option<CursorTarget>,
source: &Arc<str>,
kind: ExpressionKind,
scope: &VariableType,
on_error: OnError,
) -> VariableType {
let intellisense = self.db.graph_intellisense();
let analysis =
@@ -2101,6 +2351,7 @@ impl<'a> GraphAnalyzer<'a> {
&mut intellisense.borrow_mut(),
source,
matches!(kind, ExpressionKind::Unary),
on_error,
);
if self.validate {
self.validate_read_paths(node_id, &expression_id, &target, &analysis.reads, scope);
@@ -0,0 +1,107 @@
use crate::intellisense::type_provider::TypesProvider;
use crate::lexer::{ArithmeticOperator, ComparisonOperator, LogicalOperator, Operator};
use crate::parser::Node;
use crate::variable::VariableType;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scalar {
Number,
String,
Bool,
}
pub(crate) struct Fallible<'t> {
types: &'t TypesProvider,
}
impl<'t> Fallible<'t> {
pub(crate) fn new(types: &'t TypesProvider) -> Self {
Self { types }
}
pub(crate) fn safe(&self, node: &Node) -> bool {
match node {
Node::Null
| Node::Bool(_)
| Node::Number(_)
| Node::String(_)
| Node::Identifier(_)
| Node::Root => true,
Node::Parenthesized(inner) => self.safe(inner),
Node::Member { node, property } => {
matches!(property, Node::String(_) | Node::Number(_)) && self.safe(node)
}
Node::Array(items) => items.iter().all(|item| self.safe(item)),
Node::Object(entries) => entries
.iter()
.all(|(key, value)| matches!(key, Node::String(_)) && self.safe(value)),
Node::TemplateString(parts) => parts
.iter()
.all(|part| self.safe(part) && self.scalar(part).is_some()),
Node::Conditional {
condition,
on_true,
on_false,
} => self.typed(condition, Scalar::Bool) && self.safe(on_true) && self.safe(on_false),
Node::Unary { node, operator } => match operator {
Operator::Logical(LogicalOperator::Not) => self.typed(node, Scalar::Bool),
Operator::Arithmetic(ArithmeticOperator::Subtract | ArithmeticOperator::Add) => {
self.typed(node, Scalar::Number)
}
_ => false,
},
Node::Binary {
left,
operator,
right,
} => match operator {
Operator::Logical(LogicalOperator::NullishCoalescing)
| Operator::Comparison(ComparisonOperator::Equal | ComparisonOperator::NotEqual) => {
self.safe(left) && self.safe(right)
}
Operator::Logical(LogicalOperator::And | LogicalOperator::Or) => {
self.typed(left, Scalar::Bool) && self.typed(right, Scalar::Bool)
}
Operator::Arithmetic(
ArithmeticOperator::Subtract | ArithmeticOperator::Multiply,
)
| Operator::Comparison(
ComparisonOperator::LessThan
| ComparisonOperator::LessThanOrEqual
| ComparisonOperator::GreaterThan
| ComparisonOperator::GreaterThanOrEqual,
) => self.typed(left, Scalar::Number) && self.typed(right, Scalar::Number),
Operator::Arithmetic(ArithmeticOperator::Divide | ArithmeticOperator::Modulus) => {
self.typed(left, Scalar::Number)
&& matches!(right, Node::Number(divisor) if !divisor.is_zero())
}
Operator::Arithmetic(ArithmeticOperator::Add) => {
self.safe(left)
&& self.safe(right)
&& matches!(
(self.scalar(left), self.scalar(right)),
(Some(Scalar::Number), Some(Scalar::Number))
| (Some(Scalar::String), Some(Scalar::String))
)
}
_ => false,
},
_ => false,
}
}
fn typed(&self, node: &Node, scalar: Scalar) -> bool {
self.safe(node) && self.scalar(node) == Some(scalar)
}
fn scalar(&self, node: &Node) -> Option<Scalar> {
match &self.types.get_type(node)?.kind {
VariableType::Number => Some(Scalar::Number),
VariableType::String | VariableType::Const(_) | VariableType::Enum(..) => {
Some(Scalar::String)
}
VariableType::Bool => Some(Scalar::Bool),
_ => None,
}
}
}
+29
View File
@@ -5,6 +5,7 @@ use crate::intellisense::diagnostic::{
collect_parser_diagnostics, collect_type_diagnostics, compiler_error_to_diagnostic,
lexer_error_to_diagnostic, Diagnostic,
};
use crate::intellisense::fallible::Fallible;
use crate::intellisense::inspection::{Hover, HoverWord, InspectionResult};
use crate::intellisense::scope::IntelliSenseScope;
use crate::intellisense::type_provider::TypesProvider;
@@ -24,6 +25,7 @@ pub mod dependency;
pub mod diagnostic;
mod discriminant;
mod entity_flow;
mod fallible;
mod inspection;
pub(crate) mod scope;
pub(crate) mod type_provider;
@@ -234,6 +236,33 @@ impl IntelliSense {
}
}
pub fn can_fail(&mut self, source: &str, data: &VariableType) -> bool {
self.arena.reset();
let arena = &self.arena;
let Ok(tokens) = self.lexer.tokenize(arena, source) else {
return true;
};
let Ok(parser) = Parser::try_new(&tokens, arena) else {
return true;
};
let parser_result = parser.standard().parse();
let ast = parser_result.root;
if !parser_result.is_complete || ast.has_error() {
return true;
}
let types = TypesProvider::generate(
ast,
IntelliSenseScope {
pointer_data: data.shallow_clone(),
root_data: data.shallow_clone(),
current_data: data.shallow_clone(),
..Default::default()
},
self.strict,
);
!Fallible::new(&types).safe(ast)
}
pub fn with_ast<T>(
&mut self,
source: &str,
@@ -90,6 +90,17 @@ impl CellConstraint {
}
}
pub struct FieldPath;
impl FieldPath {
pub fn of(is: &mut IntelliSense, source: &str) -> Option<Vec<Rc<str>>> {
is.with_ast(source.trim(), false, |node, _| {
Truth::path(node).map(|path| path.into_iter().map(Rc::from).collect())
})
.flatten()
}
}
pub struct Condition;
impl Condition {
@@ -78,6 +78,15 @@ impl Interval {
}
}
fn has_integer(&self) -> bool {
let first = match self.lo {
Bound::Unbounded => return !self.is_empty(),
Bound::Inclusive(l) => Some(l.ceil()),
Bound::Exclusive(l) => l.floor().checked_add(Decimal::ONE),
};
first.is_some_and(|x| self.contains(x))
}
fn overlaps(&self, other: &Interval) -> bool {
let lo = if self.lo.lo_key() >= other.lo.lo_key() {
self.lo
@@ -182,6 +191,17 @@ impl NumberSet {
self.intervals.iter().any(|i| i.contains(x))
}
pub fn integral(&self) -> Self {
Self {
intervals: self
.intervals
.iter()
.filter(|i| i.has_integer())
.copied()
.collect(),
}
}
fn normalize(&mut self) {
self.intervals.retain(|i| !i.is_empty());
self.intervals.sort_by_key(|i| i.lo.lo_key());