mirror of
https://github.com/gorules/zen.git
synced 2026-10-05 08:02:28 +00:00
fix: cleanup
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod nullable;
|
||||
pub(crate) mod proof;
|
||||
pub(crate) mod table;
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use ahash::{HashMap, HashMapExt, HashSet};
|
||||
use zen_expression::intellisense::IntelliSense;
|
||||
use zen_expression::lexer::{ArithmeticOperator, ComparisonOperator, LogicalOperator, Operator};
|
||||
use zen_expression::parser::Node;
|
||||
|
||||
use crate::policy::linter::{AstOps, RedundantParentheses};
|
||||
use crate::analysis::proof::{FixEdit, FixProof};
|
||||
use crate::policy::linter::AstOps;
|
||||
use crate::workspace::types::{Diagnostic, DiagnosticCode, Span};
|
||||
|
||||
pub(crate) struct NullableOperand;
|
||||
|
||||
struct FallbackEdit {
|
||||
span: Span,
|
||||
range: Span,
|
||||
kept: String,
|
||||
dropped: String,
|
||||
keep_left: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fallback {
|
||||
operands: Option<(Span, Span)>,
|
||||
@@ -29,252 +23,228 @@ struct Found {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
idx: usize,
|
||||
span: Span,
|
||||
kept: Span,
|
||||
dropped: Span,
|
||||
keep_left: bool,
|
||||
wrapper: Option<Span>,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
fn edit(&self, outer: Span) -> FixEdit {
|
||||
FixEdit::unwrap(outer, self.kept, Some((self.span, self.keep_left)))
|
||||
}
|
||||
}
|
||||
|
||||
impl NullableOperand {
|
||||
pub(crate) fn annotate(
|
||||
diagnostic: &mut Diagnostic,
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
) {
|
||||
if diagnostic.code == DiagnosticCode::RedundantNullish {
|
||||
Self::fallback_fix(diagnostic, is, source, unary);
|
||||
return;
|
||||
}
|
||||
if diagnostic.code != DiagnosticCode::TypeMismatch {
|
||||
return;
|
||||
}
|
||||
let Some(span) = diagnostic.location.span else {
|
||||
return;
|
||||
};
|
||||
let Some((operator, left, right)) = Self::parse_message(&diagnostic.message) else {
|
||||
return;
|
||||
};
|
||||
let (left_nullable, right_nullable) = (left.ends_with('?'), right.ends_with('?'));
|
||||
if left_nullable == right_nullable
|
||||
|| left.trim_end_matches('?') != "number"
|
||||
|| right.trim_end_matches('?') != "number"
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(found) = Self::locate(is, source, unary, span, left_nullable) else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = found.path.filter(|p| !p.starts_with('$')) {
|
||||
diagnostic.args.insert("nullablePath", path);
|
||||
}
|
||||
let defaultable = match operator.as_str() {
|
||||
"+" | "-" | "*" | ">" | "<" | ">=" | "<=" => true,
|
||||
"/" | "%" => found.left,
|
||||
_ => false,
|
||||
};
|
||||
if !defaultable {
|
||||
return;
|
||||
}
|
||||
let operand: String = source
|
||||
.chars()
|
||||
.skip(found.operand.0 as usize)
|
||||
.take((found.operand.1 - found.operand.0) as usize)
|
||||
.collect();
|
||||
let replacement = format!("({operand} ?? 0)");
|
||||
let prefix: String = source.chars().take(found.operand.0 as usize).collect();
|
||||
let suffix: String = source.chars().skip(found.operand.1 as usize).collect();
|
||||
diagnostic
|
||||
.args
|
||||
.insert("fixSource", format!("{prefix}{replacement}{suffix}"));
|
||||
diagnostic.args.insert("fixOriginal", source.to_string());
|
||||
diagnostic.args.insert("fixOperand", operand);
|
||||
}
|
||||
|
||||
fn fallback_fix(diagnostic: &mut Diagnostic, is: &mut IntelliSense, source: &str, unary: bool) {
|
||||
let Some(edit) = Self::fallback_edit(diagnostic, is, source, unary) else {
|
||||
return;
|
||||
};
|
||||
diagnostic.args.insert("fixOriginal", source.to_string());
|
||||
diagnostic.args.insert(
|
||||
"fixSource",
|
||||
Self::splice(source, &[(edit.range, edit.kept.clone())]),
|
||||
);
|
||||
diagnostic.args.insert(
|
||||
"fixKeep",
|
||||
if edit.keep_left { "left" } else { "right" }.to_string(),
|
||||
);
|
||||
diagnostic.args.insert(
|
||||
"fixFallback",
|
||||
if edit.keep_left {
|
||||
edit.dropped
|
||||
} else {
|
||||
edit.kept
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn fallback_all(
|
||||
diagnostics: &mut [Diagnostic],
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
) {
|
||||
let mut edits: Vec<(usize, FallbackEdit)> = diagnostics
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, d)| {
|
||||
d.code == DiagnosticCode::RedundantNullish && d.args.contains_key("fixSource")
|
||||
})
|
||||
.filter_map(|(idx, d)| Some((idx, Self::fallback_edit(d, is, source, unary)?)))
|
||||
.collect();
|
||||
if edits.len() < 2 {
|
||||
return;
|
||||
}
|
||||
edits.sort_by_key(|(_, edit)| edit.range.0);
|
||||
if edits
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].1.range.1 > pair[1].1.range.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let replacements: Vec<(Span, String)> = edits
|
||||
.iter()
|
||||
.map(|(_, edit)| (edit.range, edit.kept.clone()))
|
||||
.collect();
|
||||
let combined = Self::splice(source, &replacements);
|
||||
let targets: Vec<(Span, bool)> = edits
|
||||
.iter()
|
||||
.map(|(_, edit)| (edit.span, edit.keep_left))
|
||||
.collect();
|
||||
let expected = is.with_ast(source, unary, |root, metadata| {
|
||||
let swaps: RefCell<Vec<(String, String)>> = RefCell::new(Vec::new());
|
||||
root.walk(|node| {
|
||||
let Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::NullishCoalescing),
|
||||
right,
|
||||
} = node
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(span) = AstOps::span(metadata, node) else {
|
||||
return;
|
||||
};
|
||||
if let Some((_, keep_left)) = targets.iter().find(|(target, _)| *target == span) {
|
||||
let kept = if *keep_left { *left } else { *right };
|
||||
swaps
|
||||
.borrow_mut()
|
||||
.push((format!("{node:?}"), format!("{kept:?}")));
|
||||
}
|
||||
});
|
||||
let swaps = swaps.into_inner();
|
||||
if swaps.len() != targets.len() {
|
||||
return None;
|
||||
}
|
||||
let mut debug = format!("{root:?}");
|
||||
for (from, to) in swaps {
|
||||
debug = debug.replace(&from, &to);
|
||||
}
|
||||
Some(RedundantParentheses::tree_shape(&debug))
|
||||
});
|
||||
let actual = is.with_ast(&combined, unary, |root, _| {
|
||||
RedundantParentheses::tree_shape(&format!("{root:?}"))
|
||||
});
|
||||
match (expected.flatten(), actual) {
|
||||
(Some(expected), Some(actual)) if expected == actual => {}
|
||||
_ => return,
|
||||
}
|
||||
for (idx, _) in edits {
|
||||
diagnostics[idx].args.insert("fixAll", combined.clone());
|
||||
}
|
||||
Self::default_operands(diagnostics, is, source, unary);
|
||||
Self::fallbacks(diagnostics, is, source, unary);
|
||||
}
|
||||
|
||||
fn splice(source: &str, replacements: &[(Span, String)]) -> String {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut cursor = 0usize;
|
||||
let mut sorted: Vec<&(Span, String)> = replacements.iter().collect();
|
||||
sorted.sort_by_key(|(range, _)| range.0);
|
||||
for (range, with) in sorted {
|
||||
let (start, end) = (range.0 as usize, range.1 as usize);
|
||||
out.extend(&chars[cursor.min(chars.len())..start.min(chars.len())]);
|
||||
out.push_str(with);
|
||||
cursor = end;
|
||||
}
|
||||
out.extend(&chars[cursor.min(chars.len())..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn fallback_edit(
|
||||
diagnostic: &Diagnostic,
|
||||
fn default_operands(
|
||||
diagnostics: &mut [Diagnostic],
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
) -> Option<FallbackEdit> {
|
||||
let span = diagnostic.location.span?;
|
||||
let keep_left = if diagnostic.message.contains("is never null") {
|
||||
true
|
||||
} else if diagnostic.message.contains("is always null") {
|
||||
false
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let located = is.with_ast(source, unary, |root, metadata| {
|
||||
let found: RefCell<Fallback> = RefCell::new(Fallback::default());
|
||||
root.walk(|node| match node {
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::NullishCoalescing),
|
||||
right,
|
||||
} if AstOps::span(metadata, node) == Some(span) => {
|
||||
let (kept, dropped) = if keep_left {
|
||||
(*left, *right)
|
||||
} else {
|
||||
(*right, *left)
|
||||
};
|
||||
if let (Some(kept), Some(dropped)) = (
|
||||
AstOps::span(metadata, kept),
|
||||
AstOps::span(metadata, dropped),
|
||||
) {
|
||||
found.borrow_mut().operands = Some((kept, dropped));
|
||||
) {
|
||||
let requests: Vec<(usize, Span, String, bool)> = diagnostics
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, d)| d.code == DiagnosticCode::TypeMismatch)
|
||||
.filter_map(|(idx, d)| {
|
||||
let span = d.location.span?;
|
||||
let (operator, left, right) = Self::parse_message(&d.message)?;
|
||||
let (left_nullable, right_nullable) = (left.ends_with('?'), right.ends_with('?'));
|
||||
(left_nullable != right_nullable
|
||||
&& left.trim_end_matches('?') == "number"
|
||||
&& right.trim_end_matches('?') == "number")
|
||||
.then_some((idx, span, operator, left_nullable))
|
||||
})
|
||||
.collect();
|
||||
if requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let spans: HashSet<Span> = requests.iter().map(|(_, span, _, _)| *span).collect();
|
||||
let operands = Self::locate(is, source, unary, &spans);
|
||||
for (idx, span, operator, left_nullable) in requests {
|
||||
let Some((left, right)) = operands.get(&span) else {
|
||||
continue;
|
||||
};
|
||||
let found = if left_nullable { left } else { right };
|
||||
let diagnostic = &mut diagnostics[idx];
|
||||
if let Some(path) = found.path.as_ref().filter(|p| !p.starts_with('$')) {
|
||||
diagnostic.args.insert("nullablePath", path.clone());
|
||||
}
|
||||
let defaultable = match operator.as_str() {
|
||||
"+" | "-" | "*" | ">" | "<" | ">=" | "<=" => true,
|
||||
"/" | "%" => found.left,
|
||||
_ => false,
|
||||
};
|
||||
if !defaultable {
|
||||
continue;
|
||||
}
|
||||
let Some(operand) = AstOps::text(source, found.operand) else {
|
||||
continue;
|
||||
};
|
||||
let replacement = format!("({operand} ?? 0)");
|
||||
let Some(fixed) = AstOps::splice(source, &[(found.operand, replacement.as_str())])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
diagnostic.args.insert("fixSource", fixed);
|
||||
diagnostic.args.insert("fixOriginal", source.to_string());
|
||||
diagnostic.args.insert("fixOperand", operand.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn fallbacks(diagnostics: &mut [Diagnostic], is: &mut IntelliSense, source: &str, unary: bool) {
|
||||
let candidates = Self::candidates(diagnostics, is, source, unary);
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
let preferred: Vec<FixEdit> = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.edit(candidate.wrapper.unwrap_or(candidate.span)))
|
||||
.collect();
|
||||
let mut edits: Vec<Option<FixEdit>> = FixProof::proven(is, source, unary, &preferred)
|
||||
.into_iter()
|
||||
.zip(preferred)
|
||||
.map(|(proven, edit)| proven.then_some(edit))
|
||||
.collect();
|
||||
let retry: Vec<usize> = (0..candidates.len())
|
||||
.filter(|&i| edits[i].is_none() && candidates[i].wrapper.is_some())
|
||||
.collect();
|
||||
let alternatives: Vec<FixEdit> = retry
|
||||
.iter()
|
||||
.map(|&i| candidates[i].edit(candidates[i].span))
|
||||
.collect();
|
||||
for ((i, proven), edit) in retry
|
||||
.into_iter()
|
||||
.zip(FixProof::proven(is, source, unary, &alternatives))
|
||||
.zip(alternatives)
|
||||
{
|
||||
if proven {
|
||||
edits[i] = Some(edit);
|
||||
}
|
||||
}
|
||||
let accepted: Vec<&FixEdit> = edits.iter().flatten().collect();
|
||||
let fix_all = (accepted.len() > 1)
|
||||
.then(|| FixProof::holds(is, source, unary, &accepted))
|
||||
.flatten();
|
||||
for (candidate, edit) in candidates.iter().zip(&edits) {
|
||||
let Some(fixed) = edit.as_ref().and_then(|edit| edit.apply(source)) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(kept), Some(dropped)) = (
|
||||
AstOps::text(source, candidate.kept),
|
||||
AstOps::text(source, candidate.dropped),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let args = &mut diagnostics[candidate.idx].args;
|
||||
args.insert("fixOriginal", source.to_string());
|
||||
args.insert("fixSource", fixed);
|
||||
args.insert(
|
||||
"fixKeep",
|
||||
if candidate.keep_left { "left" } else { "right" }.to_string(),
|
||||
);
|
||||
args.insert(
|
||||
"fixFallback",
|
||||
if candidate.keep_left { dropped } else { kept }.to_string(),
|
||||
);
|
||||
if let Some(all) = &fix_all {
|
||||
args.insert("fixAll", all.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidates(
|
||||
diagnostics: &[Diagnostic],
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
) -> Vec<Candidate> {
|
||||
let targets: Vec<(usize, Span, bool)> = diagnostics
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, d)| d.code == DiagnosticCode::RedundantNullish)
|
||||
.filter_map(|(idx, d)| {
|
||||
let keep_left = if d.message.contains("is never null") {
|
||||
true
|
||||
} else if d.message.contains("is always null") {
|
||||
false
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((idx, d.location.span?, keep_left))
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let spans: HashSet<Span> = targets.iter().map(|(_, span, _)| *span).collect();
|
||||
let located = is
|
||||
.with_ast(source, unary, |root, metadata| {
|
||||
let found: RefCell<HashMap<Span, Fallback>> = RefCell::new(HashMap::new());
|
||||
root.walk(|node| match node {
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::NullishCoalescing),
|
||||
right,
|
||||
} => {
|
||||
let Some(span) =
|
||||
AstOps::span(metadata, node).filter(|span| spans.contains(span))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let (Some(left), Some(right)) =
|
||||
(AstOps::span(metadata, left), AstOps::span(metadata, right))
|
||||
{
|
||||
found.borrow_mut().entry(span).or_default().operands =
|
||||
Some((left, right));
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Parenthesized(inner) if AstOps::span(metadata, inner) == Some(span) => {
|
||||
found.borrow_mut().wrapper = AstOps::span(metadata, node);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
found.into_inner()
|
||||
})?;
|
||||
let Fallback {
|
||||
operands: Some((kept, dropped)),
|
||||
wrapper,
|
||||
} = located
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let text = |range: Span| -> String {
|
||||
source
|
||||
.chars()
|
||||
.skip(range.0 as usize)
|
||||
.take((range.1 - range.0) as usize)
|
||||
.collect()
|
||||
};
|
||||
let kept_text = text(kept);
|
||||
let mut shape = |candidate: &str| {
|
||||
is.with_ast(candidate, unary, |root, _| {
|
||||
RedundantParentheses::tree_shape(&format!("{root:?}"))
|
||||
Node::Parenthesized(inner) => {
|
||||
if let Some(span) =
|
||||
AstOps::span(metadata, inner).filter(|span| spans.contains(span))
|
||||
{
|
||||
found.borrow_mut().entry(span).or_default().wrapper =
|
||||
AstOps::span(metadata, node);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
found.into_inner()
|
||||
})
|
||||
};
|
||||
let plain_shape = shape(&Self::splice(source, &[(span, kept_text.clone())]))?;
|
||||
let range = wrapper
|
||||
.filter(|wrapper| {
|
||||
shape(&Self::splice(source, &[(*wrapper, kept_text.clone())])).as_deref()
|
||||
== Some(plain_shape.as_str())
|
||||
.unwrap_or_default();
|
||||
targets
|
||||
.into_iter()
|
||||
.filter_map(|(idx, span, keep_left)| {
|
||||
let fallback = located.get(&span)?;
|
||||
let (left, right) = fallback.operands?;
|
||||
let (kept, dropped) = if keep_left {
|
||||
(left, right)
|
||||
} else {
|
||||
(right, left)
|
||||
};
|
||||
Some(Candidate {
|
||||
idx,
|
||||
span,
|
||||
kept,
|
||||
dropped,
|
||||
keep_left,
|
||||
wrapper: fallback.wrapper,
|
||||
})
|
||||
})
|
||||
.unwrap_or(span);
|
||||
Some(FallbackEdit {
|
||||
span,
|
||||
range,
|
||||
kept: kept_text,
|
||||
dropped: text(dropped),
|
||||
keep_left,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_message(message: &str) -> Option<(String, String, String)> {
|
||||
@@ -292,11 +262,10 @@ impl NullableOperand {
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
span: Span,
|
||||
left_nullable: bool,
|
||||
) -> Option<Found> {
|
||||
spans: &HashSet<Span>,
|
||||
) -> HashMap<Span, (Found, Found)> {
|
||||
is.with_ast(source, unary, |root, metadata| {
|
||||
let found: RefCell<Option<Found>> = RefCell::new(None);
|
||||
let found: RefCell<HashMap<Span, (Found, Found)>> = RefCell::new(HashMap::new());
|
||||
root.walk(|node| {
|
||||
let Node::Binary {
|
||||
left,
|
||||
@@ -321,22 +290,37 @@ impl NullableOperand {
|
||||
| ComparisonOperator::GreaterThanOrEqual
|
||||
)
|
||||
);
|
||||
if !numeric || AstOps::span(metadata, node) != Some(span) {
|
||||
if !numeric {
|
||||
return;
|
||||
}
|
||||
let operand = if left_nullable { *left } else { *right };
|
||||
let Some(operand_span) = AstOps::span(metadata, operand) else {
|
||||
let Some(span) = AstOps::span(metadata, node).filter(|span| spans.contains(span))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
found.replace(Some(Found {
|
||||
operand: operand_span,
|
||||
left: left_nullable,
|
||||
path: Self::path(operand),
|
||||
}));
|
||||
let (Some(left_span), Some(right_span)) =
|
||||
(AstOps::span(metadata, left), AstOps::span(metadata, right))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
found.borrow_mut().insert(
|
||||
span,
|
||||
(
|
||||
Found {
|
||||
operand: left_span,
|
||||
left: true,
|
||||
path: Self::path(left),
|
||||
},
|
||||
Found {
|
||||
operand: right_span,
|
||||
left: false,
|
||||
path: Self::path(right),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
found.into_inner()
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn path(node: &Node) -> Option<String> {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use ahash::{HashMap, HashMapExt, HashSet};
|
||||
use zen_expression::intellisense::{AstMetadata, IntelliSense};
|
||||
use zen_expression::lexer::{LogicalOperator, Operator};
|
||||
use zen_expression::parser::Node;
|
||||
|
||||
use crate::policy::linter::AstOps;
|
||||
use crate::workspace::types::Span;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FixEdit {
|
||||
pub(crate) deletions: Vec<Span>,
|
||||
pub(crate) kept: Span,
|
||||
pub(crate) swap: Option<(Span, bool)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Layer {
|
||||
members: Vec<usize>,
|
||||
taken: BTreeMap<u32, u32>,
|
||||
targets: HashSet<Span>,
|
||||
kept: HashSet<Span>,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
fn admits(&self, edit: &FixEdit) -> bool {
|
||||
let free = edit.deletions.iter().all(|(start, end)| {
|
||||
self.taken
|
||||
.range(..*end)
|
||||
.next_back()
|
||||
.is_none_or(|(_, taken_end)| taken_end <= start)
|
||||
});
|
||||
free && edit.swap.is_none_or(|(target, _)| {
|
||||
!self.kept.contains(&target) && !self.targets.contains(&edit.kept)
|
||||
})
|
||||
}
|
||||
|
||||
fn insert(&mut self, idx: usize, edit: &FixEdit) {
|
||||
self.members.push(idx);
|
||||
self.taken.extend(edit.deletions.iter().copied());
|
||||
if let Some((target, _)) = edit.swap {
|
||||
self.targets.insert(target);
|
||||
self.kept.insert(edit.kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FixEdit {
|
||||
pub(crate) fn unwrap(outer: Span, kept: Span, swap: Option<(Span, bool)>) -> Self {
|
||||
Self {
|
||||
deletions: [(outer.0, kept.0), (kept.1, outer.1)]
|
||||
.into_iter()
|
||||
.filter(|(start, end)| start < end)
|
||||
.collect(),
|
||||
kept,
|
||||
swap,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, source: &str) -> Option<String> {
|
||||
FixProof::splice(source, &[self])
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FixProof;
|
||||
|
||||
impl FixProof {
|
||||
pub(crate) fn proven(
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
edits: &[FixEdit],
|
||||
) -> Vec<bool> {
|
||||
let mut proven = vec![false; edits.len()];
|
||||
let mut layers: Vec<Layer> = Vec::new();
|
||||
for (idx, edit) in edits.iter().enumerate() {
|
||||
match layers.iter_mut().find(|layer| layer.admits(edit)) {
|
||||
Some(layer) => layer.insert(idx, edit),
|
||||
None => {
|
||||
let mut layer = Layer::default();
|
||||
layer.insert(idx, edit);
|
||||
layers.push(layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
for layer in layers {
|
||||
Self::bisect(is, source, unary, edits, &layer.members, &mut proven);
|
||||
}
|
||||
proven
|
||||
}
|
||||
|
||||
pub(crate) fn holds(
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
edits: &[&FixEdit],
|
||||
) -> Option<String> {
|
||||
let fixed = Self::splice(source, edits)?;
|
||||
let swaps: HashMap<Span, bool> = edits.iter().filter_map(|edit| edit.swap).collect();
|
||||
let expected = is
|
||||
.with_ast(source, unary, |root, metadata| {
|
||||
Shape::of(root, metadata, &swaps)
|
||||
})
|
||||
.flatten()?;
|
||||
let actual = is
|
||||
.with_ast(&fixed, unary, |root, metadata| {
|
||||
Shape::of(root, metadata, &HashMap::new())
|
||||
})
|
||||
.flatten()?;
|
||||
(expected == actual).then_some(fixed)
|
||||
}
|
||||
|
||||
fn bisect(
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
unary: bool,
|
||||
edits: &[FixEdit],
|
||||
members: &[usize],
|
||||
proven: &mut [bool],
|
||||
) {
|
||||
if members.is_empty() {
|
||||
return;
|
||||
}
|
||||
let batch: Vec<&FixEdit> = members.iter().map(|&idx| &edits[idx]).collect();
|
||||
if Self::holds(is, source, unary, &batch).is_some() {
|
||||
members.iter().for_each(|&idx| proven[idx] = true);
|
||||
return;
|
||||
}
|
||||
if members.len() == 1 {
|
||||
return;
|
||||
}
|
||||
let (left, right) = members.split_at(members.len() / 2);
|
||||
Self::bisect(is, source, unary, edits, left, proven);
|
||||
Self::bisect(is, source, unary, edits, right, proven);
|
||||
}
|
||||
|
||||
fn splice(source: &str, edits: &[&FixEdit]) -> Option<String> {
|
||||
let deletions: Vec<(Span, &str)> = edits
|
||||
.iter()
|
||||
.flat_map(|edit| edit.deletions.iter().map(|span| (*span, "")))
|
||||
.collect();
|
||||
AstOps::splice(source, &deletions)
|
||||
}
|
||||
}
|
||||
|
||||
struct Shape<'m> {
|
||||
metadata: &'m AstMetadata,
|
||||
swaps: &'m HashMap<Span, bool>,
|
||||
matched: usize,
|
||||
out: String,
|
||||
}
|
||||
|
||||
impl<'m> Shape<'m> {
|
||||
fn of(
|
||||
root: &Node,
|
||||
metadata: &'m AstMetadata,
|
||||
swaps: &'m HashMap<Span, bool>,
|
||||
) -> Option<String> {
|
||||
let mut shape = Shape {
|
||||
metadata,
|
||||
swaps,
|
||||
matched: 0,
|
||||
out: String::new(),
|
||||
};
|
||||
shape.write(root);
|
||||
(shape.matched == swaps.len()).then_some(shape.out)
|
||||
}
|
||||
|
||||
fn write(&mut self, node: &Node) {
|
||||
if let Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::NullishCoalescing),
|
||||
right,
|
||||
} = node
|
||||
{
|
||||
if let Some(keep_left) = AstOps::span(self.metadata, node)
|
||||
.and_then(|span| self.swaps.get(&span))
|
||||
.copied()
|
||||
{
|
||||
self.matched += 1;
|
||||
return self.write(if keep_left { left } else { right });
|
||||
}
|
||||
}
|
||||
match node {
|
||||
Node::Parenthesized(inner) => return self.write(inner),
|
||||
Node::Null
|
||||
| Node::Bool(_)
|
||||
| Node::Number(_)
|
||||
| Node::String(_)
|
||||
| Node::Pointer
|
||||
| Node::Identifier(_)
|
||||
| Node::Root => {
|
||||
let _ = write!(self.out, "{node:?};");
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.out.push_str(node.into());
|
||||
let _ = match node {
|
||||
Node::Closure { alias, .. } => write!(self.out, "{alias:?}"),
|
||||
Node::Interval {
|
||||
left_bracket,
|
||||
right_bracket,
|
||||
..
|
||||
} => write!(self.out, "{left_bracket:?}{right_bracket:?}"),
|
||||
Node::Unary { operator, .. } | Node::Binary { operator, .. } => {
|
||||
write!(self.out, "{operator:?}")
|
||||
}
|
||||
Node::FunctionCall { kind, .. } => write!(self.out, "{kind:?}"),
|
||||
Node::MethodCall { kind, .. } => write!(self.out, "{kind:?}"),
|
||||
Node::Error { error, .. } => write!(self.out, "{error:?}"),
|
||||
_ => Ok(()),
|
||||
};
|
||||
self.out.push('(');
|
||||
match node {
|
||||
Node::TemplateString(items) | Node::Array(items) => {
|
||||
items.iter().for_each(|item| self.write(item))
|
||||
}
|
||||
Node::Object(entries) => entries.iter().for_each(|(key, value)| {
|
||||
self.write(key);
|
||||
self.write(value);
|
||||
}),
|
||||
Node::Assignments { list, output } => {
|
||||
list.iter().for_each(|(key, value)| {
|
||||
self.write(key);
|
||||
self.write(value);
|
||||
});
|
||||
self.optional(*output);
|
||||
}
|
||||
Node::Closure { body, .. } => self.write(body),
|
||||
Node::Member { node, property } => {
|
||||
self.write(node);
|
||||
self.write(property);
|
||||
}
|
||||
Node::Slice { node, from, to } => {
|
||||
self.write(node);
|
||||
self.optional(*from);
|
||||
self.optional(*to);
|
||||
}
|
||||
Node::Interval { left, right, .. } | Node::Binary { left, right, .. } => {
|
||||
self.write(left);
|
||||
self.write(right);
|
||||
}
|
||||
Node::Conditional {
|
||||
condition,
|
||||
on_true,
|
||||
on_false,
|
||||
} => {
|
||||
self.write(condition);
|
||||
self.write(on_true);
|
||||
self.write(on_false);
|
||||
}
|
||||
Node::Unary { node, .. } => self.write(node),
|
||||
Node::FunctionCall { arguments, .. } => {
|
||||
arguments.iter().for_each(|argument| self.write(argument))
|
||||
}
|
||||
Node::MethodCall {
|
||||
this, arguments, ..
|
||||
} => {
|
||||
self.write(this);
|
||||
arguments.iter().for_each(|argument| self.write(argument));
|
||||
}
|
||||
Node::Error { node, .. } => self.optional(*node),
|
||||
_ => {}
|
||||
}
|
||||
self.out.push(')');
|
||||
}
|
||||
|
||||
fn optional(&mut self, node: Option<&Node>) {
|
||||
match node {
|
||||
Some(node) => self.write(node),
|
||||
None => self.out.push('_'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
@@ -45,24 +46,41 @@ impl CellConstraint {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(truth, format!("{node:?}"))
|
||||
match truth {
|
||||
Some(truth) => Ok(truth.t),
|
||||
None => Err((Self::is_random(node), format!("{node:?}"))),
|
||||
}
|
||||
});
|
||||
match parsed {
|
||||
Some((Some(truth), _)) => CellConstraint::Known(truth.t),
|
||||
Some((None, key)) => CellConstraint::Opaque(Self::atom_key(trimmed, Some(key))),
|
||||
None => CellConstraint::Opaque(Self::atom_key(trimmed, None)),
|
||||
Some(Ok(set)) => CellConstraint::Known(set),
|
||||
Some(Err((random, key))) => CellConstraint::Opaque(Self::atom_key(random, key)),
|
||||
None => CellConstraint::Opaque(Self::atom_key(false, format!("src:{trimmed}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn atom_key(source: &str, ast: Option<String>) -> Rc<str> {
|
||||
if source.contains("rand(") {
|
||||
fn is_random(node: &Node) -> bool {
|
||||
let random = Cell::new(false);
|
||||
node.walk(|n| {
|
||||
if let Node::FunctionCall {
|
||||
kind: FunctionKind::Internal(InternalFunction::Rand),
|
||||
..
|
||||
} = n
|
||||
{
|
||||
random.set(true);
|
||||
}
|
||||
});
|
||||
random.get()
|
||||
}
|
||||
|
||||
fn atom_key(random: bool, key: String) -> Rc<str> {
|
||||
if random {
|
||||
static UNIQUE: AtomicUsize = AtomicUsize::new(0);
|
||||
return Rc::from(format!(
|
||||
"unique:{}",
|
||||
UNIQUE.fetch_add(1, AtomicOrdering::Relaxed)
|
||||
));
|
||||
}
|
||||
Rc::from(ast.unwrap_or_else(|| format!("src:{source}")))
|
||||
Rc::from(key)
|
||||
}
|
||||
|
||||
pub(crate) fn known_set(&self) -> Option<ValueSet> {
|
||||
@@ -134,10 +152,79 @@ struct Truth {
|
||||
}
|
||||
|
||||
impl Truth {
|
||||
fn unwrap<'a, 'n>(node: &'a Node<'n>) -> &'a Node<'n> {
|
||||
match node {
|
||||
Node::Parenthesized(inner) => Self::unwrap(inner),
|
||||
other => other,
|
||||
fn unwrap<'a, 'n>(mut node: &'a Node<'n>) -> &'a Node<'n> {
|
||||
while let Node::Parenthesized(inner) = node {
|
||||
node = inner;
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn chain<'a, 'n>(node: &'a Node<'n>, op: LogicalOperator) -> Vec<&'a Node<'n>> {
|
||||
let mut operands = Vec::new();
|
||||
let mut pending = vec![node];
|
||||
while let Some(next) = pending.pop() {
|
||||
match Self::unwrap(next) {
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(found),
|
||||
right,
|
||||
} if *found == op => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
other => operands.push(other),
|
||||
}
|
||||
}
|
||||
operands
|
||||
}
|
||||
|
||||
fn all(node: &Node, cx: &Scope) -> Option<Truth> {
|
||||
let mut operands = Self::chain(node, LogicalOperator::And).into_iter();
|
||||
let mut acc = Self::of(operands.next()?, cx)?;
|
||||
for operand in operands {
|
||||
let b = Self::of(operand, cx)?;
|
||||
acc = Truth {
|
||||
f: acc.f.union(&acc.t.intersect(&b.f)),
|
||||
t: acc.t.intersect(&b.t),
|
||||
};
|
||||
}
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
fn any(node: &Node, cx: &Scope) -> Option<Truth> {
|
||||
let mut acc: Option<Truth> = None;
|
||||
let mut total: Vec<ValueSet> = Vec::new();
|
||||
for operand in Self::chain(node, LogicalOperator::Or) {
|
||||
let b = Self::of(operand, cx)?;
|
||||
if !b.t.intersects(&b.f) && b.t.union(&b.f).is_all() {
|
||||
total.push(b.t);
|
||||
continue;
|
||||
}
|
||||
if !total.is_empty() {
|
||||
acc = Some(Self::either(acc, Self::total(&total)));
|
||||
total.clear();
|
||||
}
|
||||
acc = Some(Self::either(acc, b));
|
||||
}
|
||||
if !total.is_empty() {
|
||||
acc = Some(Self::either(acc, Self::total(&total)));
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
fn total(sets: &[ValueSet]) -> Truth {
|
||||
let t = ValueSet::union_all(sets);
|
||||
let f = t.complement();
|
||||
Truth { t, f }
|
||||
}
|
||||
|
||||
fn either(acc: Option<Truth>, b: Truth) -> Truth {
|
||||
match acc {
|
||||
None => b,
|
||||
Some(a) => Truth {
|
||||
t: a.t.union(&a.f.intersect(&b.t)),
|
||||
f: a.f.intersect(&b.f),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,29 +242,13 @@ impl Truth {
|
||||
f: inner.t,
|
||||
}),
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::And),
|
||||
right,
|
||||
} => {
|
||||
let a = Self::of(left, cx)?;
|
||||
let b = Self::of(right, cx)?;
|
||||
Some(Truth {
|
||||
t: a.t.intersect(&b.t),
|
||||
f: a.f.union(&a.t.intersect(&b.f)),
|
||||
})
|
||||
}
|
||||
..
|
||||
} => Self::all(node, cx),
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::Or),
|
||||
right,
|
||||
} => {
|
||||
let a = Self::of(left, cx)?;
|
||||
let b = Self::of(right, cx)?;
|
||||
Some(Truth {
|
||||
t: a.t.union(&a.f.intersect(&b.t)),
|
||||
f: a.f.intersect(&b.f),
|
||||
})
|
||||
}
|
||||
..
|
||||
} => Self::any(node, cx),
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Comparison(op),
|
||||
@@ -222,42 +293,34 @@ impl Truth {
|
||||
}
|
||||
}
|
||||
|
||||
fn subject<'a>(node: &Node<'a>) -> Option<Vec<&'a str>> {
|
||||
match Self::unwrap(node) {
|
||||
Node::FunctionCall {
|
||||
kind: FunctionKind::Internal(InternalFunction::Bool),
|
||||
arguments: [argument],
|
||||
} => Self::subject(argument),
|
||||
Node::Unary {
|
||||
operator: Operator::Logical(LogicalOperator::Not),
|
||||
node,
|
||||
} => Self::subject(node),
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::And | LogicalOperator::Or),
|
||||
..
|
||||
} => Self::subject(left),
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Comparison(_),
|
||||
right,
|
||||
} => Self::path(left).or_else(|| Self::path(right)),
|
||||
_ => None,
|
||||
fn subject<'a>(mut node: &Node<'a>) -> Option<Vec<&'a str>> {
|
||||
loop {
|
||||
node = match Self::unwrap(node) {
|
||||
Node::FunctionCall {
|
||||
kind: FunctionKind::Internal(InternalFunction::Bool),
|
||||
arguments: [argument],
|
||||
} => argument,
|
||||
Node::Unary {
|
||||
operator: Operator::Logical(LogicalOperator::Not),
|
||||
node,
|
||||
} => node,
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::And | LogicalOperator::Or),
|
||||
..
|
||||
} => left,
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Comparison(_),
|
||||
right,
|
||||
} => return Self::path(left).or_else(|| Self::path(right)),
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn conjuncts<'n, 'a>(node: &'n Node<'a>, out: &mut Vec<&'n Node<'a>>) {
|
||||
match Self::unwrap(node) {
|
||||
Node::Binary {
|
||||
left,
|
||||
operator: Operator::Logical(LogicalOperator::And),
|
||||
right,
|
||||
} => {
|
||||
Self::conjuncts(left, out);
|
||||
Self::conjuncts(right, out);
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
out.extend(Self::chain(node, LogicalOperator::And));
|
||||
}
|
||||
|
||||
fn comparison(left: &Node, op: ComparisonOperator, right: &Node, cx: &Scope) -> Option<Truth> {
|
||||
@@ -319,10 +382,11 @@ impl Truth {
|
||||
fn membership(right: &Node, cx: &Scope) -> Option<Truth> {
|
||||
match Self::unwrap(right) {
|
||||
Node::Array(items) => {
|
||||
let mut t = ValueSet::empty();
|
||||
for item in items.iter() {
|
||||
t = t.union(&Self::literal(item, cx)?);
|
||||
}
|
||||
let literals = items
|
||||
.iter()
|
||||
.map(|item| Self::literal(item, cx))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let t = ValueSet::union_all(&literals);
|
||||
let f = ValueSet::scalars().difference(&t);
|
||||
Some(Truth { t, f })
|
||||
}
|
||||
|
||||
@@ -154,9 +154,10 @@ impl RowIndex {
|
||||
pub(super) fn inner_points(&self, col: usize, interval: &Interval) -> Vec<Decimal> {
|
||||
let points = &self.columns[col].points;
|
||||
match points.range(interval) {
|
||||
Some((first, last)) => {
|
||||
vec![points.representative(last), points.representative(first)]
|
||||
}
|
||||
Some((first, last)) => [points.representative(last), points.representative(first)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,14 +321,15 @@ impl VerifyTable<'_> {
|
||||
}
|
||||
let fresh: BTreeSet<Rc<str>> =
|
||||
b.iter().filter(|key| !a.contains(*key)).cloned().collect();
|
||||
if fresh.iter().any(|key| CellText::string(key).is_none()) {
|
||||
return false;
|
||||
}
|
||||
let mut moved = Self::region(next_row);
|
||||
moved[col] = ValueSet {
|
||||
strings: StringSet::Finite(fresh.clone()),
|
||||
..ValueSet::empty()
|
||||
};
|
||||
if self.mode != HitMode::Collect
|
||||
&& !Self::clear_between(rows, keep, next, &moved, work, index)
|
||||
{
|
||||
if !Self::clear_between(rows, keep, next, &moved, work, index) {
|
||||
return false;
|
||||
}
|
||||
if let Some(row) = rows[keep].as_mut() {
|
||||
@@ -361,9 +362,7 @@ impl VerifyTable<'_> {
|
||||
.enumerate()
|
||||
.map(|(idx, set)| if idx == col { set.difference(&a) } else { set })
|
||||
.collect();
|
||||
if self.mode != HitMode::Collect
|
||||
&& !Self::clear_between(rows, keep, next, &moved, work, index)
|
||||
{
|
||||
if !Self::clear_between(rows, keep, next, &moved, work, index) {
|
||||
return false;
|
||||
}
|
||||
let union = a.union(&b);
|
||||
|
||||
@@ -46,14 +46,14 @@ impl Points {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn representative(&self, piece: usize) -> Decimal {
|
||||
pub(super) fn representative(&self, piece: usize) -> Option<Decimal> {
|
||||
let points = &self.0;
|
||||
match (piece % 2, points.len()) {
|
||||
(1, _) => points[piece / 2],
|
||||
(_, 0) => Decimal::ZERO,
|
||||
_ if piece == 0 => points[0] - Decimal::ONE,
|
||||
_ if piece / 2 == points.len() => points[points.len() - 1] + Decimal::ONE,
|
||||
_ => (points[piece / 2 - 1] + points[piece / 2]) / Decimal::TWO,
|
||||
(1, _) => Some(points[piece / 2]),
|
||||
(_, 0) => Some(Decimal::ZERO),
|
||||
_ if piece == 0 => points[0].checked_sub(Decimal::ONE),
|
||||
_ if piece / 2 == points.len() => points[points.len() - 1].checked_add(Decimal::ONE),
|
||||
_ => Interval::midpoint(points[piece / 2 - 1], points[piece / 2]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use rust_decimal::prelude::ToPrimitive;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::value_set::{decimal_json, Bound, Interval, StringSet, ValueSet};
|
||||
use super::value_set::{Bound, Interval, StringSet, ValueSet};
|
||||
|
||||
pub(crate) struct DateDay;
|
||||
|
||||
@@ -81,13 +81,13 @@ impl DateDay {
|
||||
let day = Decimal::from(Self::DAY);
|
||||
for interval in set.numbers.intervals() {
|
||||
let candidates = match (interval.lo, interval.hi) {
|
||||
(Bound::Inclusive(a), _) => vec![a, a + day],
|
||||
(Bound::Exclusive(a), _) => vec![a + day],
|
||||
(Bound::Unbounded, Bound::Inclusive(b)) => vec![b, b - day],
|
||||
(Bound::Unbounded, Bound::Exclusive(b)) => vec![b - day],
|
||||
(Bound::Unbounded, Bound::Unbounded) => vec![Decimal::ZERO],
|
||||
(Bound::Inclusive(a), _) => vec![Some(a), a.checked_add(day)],
|
||||
(Bound::Exclusive(a), _) => vec![a.checked_add(day)],
|
||||
(Bound::Unbounded, Bound::Inclusive(b)) => vec![Some(b), b.checked_sub(day)],
|
||||
(Bound::Unbounded, Bound::Exclusive(b)) => vec![b.checked_sub(day)],
|
||||
(Bound::Unbounded, Bound::Unbounded) => vec![Some(Decimal::ZERO)],
|
||||
};
|
||||
let found = candidates.into_iter().find_map(|c| {
|
||||
let found = candidates.into_iter().flatten().find_map(|c| {
|
||||
ValueSet::number(c)
|
||||
.is_subset(set)
|
||||
.then(|| Self::format(c))
|
||||
@@ -107,20 +107,19 @@ impl CellText {
|
||||
pub(crate) fn brief(text: &str) -> String {
|
||||
const KEEP: usize = 6;
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut quoted = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut depth = 0usize;
|
||||
let mut commas: Vec<(usize, usize)> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
match chars[i] {
|
||||
'\\' if quoted => i += 1,
|
||||
'"' => quoted = !quoted,
|
||||
'[' | '(' if !quoted => depth += 1,
|
||||
']' | ')' if !quoted => depth = depth.saturating_sub(1),
|
||||
',' if !quoted && depth <= 1 => commas.push((i, depth)),
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match (quote, c) {
|
||||
(Some(open), _) if c == open => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'' | '`') => quote = Some(c),
|
||||
(None, '[' | '(') => depth += 1,
|
||||
(None, ']' | ')') => depth = depth.saturating_sub(1),
|
||||
(None, ',') if depth <= 1 => commas.push((i, depth)),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if commas.len() < KEEP + 2 {
|
||||
return text.to_string();
|
||||
@@ -143,7 +142,7 @@ impl CellText {
|
||||
return None;
|
||||
}
|
||||
let positive = Self::positive(&wanted, dated);
|
||||
let negative = Self::negative(&domain.difference(&wanted), dated);
|
||||
let negative = Self::negative(&domain.difference(&wanted), wanted.other, dated);
|
||||
match (positive, negative) {
|
||||
(Some(p), Some(n)) if n.len() < p.len() => Some(n),
|
||||
(Some(p), _) => Some(p),
|
||||
@@ -173,7 +172,11 @@ impl CellText {
|
||||
}
|
||||
}
|
||||
match &set.strings {
|
||||
StringSet::Finite(values) => tokens.extend(values.iter().map(|v| Self::string(v))),
|
||||
StringSet::Finite(values) => {
|
||||
for value in values {
|
||||
tokens.push(Self::string(value)?);
|
||||
}
|
||||
}
|
||||
StringSet::CoFinite(_) => return None,
|
||||
}
|
||||
if set.bools & ValueSet::TRUE != 0 {
|
||||
@@ -188,7 +191,7 @@ impl CellText {
|
||||
(!tokens.is_empty()).then(|| tokens.join(", "))
|
||||
}
|
||||
|
||||
fn negative(excluded: &ValueSet, dated: bool) -> Option<String> {
|
||||
fn negative(excluded: &ValueSet, other: bool, dated: bool) -> Option<String> {
|
||||
if excluded.other || (dated && !excluded.strings.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
@@ -202,7 +205,11 @@ impl CellText {
|
||||
}
|
||||
}
|
||||
match &excluded.strings {
|
||||
StringSet::Finite(values) => points.extend(values.iter().map(|v| Self::string(v))),
|
||||
StringSet::Finite(values) => {
|
||||
for value in values {
|
||||
points.push(Self::string(value)?);
|
||||
}
|
||||
}
|
||||
StringSet::CoFinite(_) => return None,
|
||||
}
|
||||
if excluded.bools & ValueSet::TRUE != 0 {
|
||||
@@ -217,6 +224,13 @@ impl CellText {
|
||||
match points.as_slice() {
|
||||
[] => None,
|
||||
[single] => Some(format!("!= {single}")),
|
||||
_ if other => Some(
|
||||
points
|
||||
.iter()
|
||||
.map(|point| format!("!= {point}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and "),
|
||||
),
|
||||
_ => Some(format!("not in [{}]", points.join(", "))),
|
||||
}
|
||||
}
|
||||
@@ -260,16 +274,17 @@ impl CellText {
|
||||
|
||||
fn number(d: Decimal, dated: bool) -> Option<String> {
|
||||
if dated {
|
||||
return DateDay::format(d).map(|text| Self::string(&text));
|
||||
return DateDay::format(d).and_then(|text| Self::string(&text));
|
||||
}
|
||||
Some(match decimal_json(d) {
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => d.normalize().to_string(),
|
||||
})
|
||||
Some(d.normalize().to_string())
|
||||
}
|
||||
|
||||
fn string(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
|
||||
pub(crate) fn string(s: &str) -> Option<String> {
|
||||
match (s.contains('"'), s.contains('\'')) {
|
||||
(false, _) => Some(format!("\"{s}\"")),
|
||||
(true, false) => Some(format!("'{s}'")),
|
||||
(true, true) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,22 +124,35 @@ impl Interval {
|
||||
above && below
|
||||
}
|
||||
|
||||
fn example(&self) -> Decimal {
|
||||
pub(crate) fn midpoint(l: Decimal, h: Decimal) -> Option<Decimal> {
|
||||
[
|
||||
l.checked_add(h).map(|sum| sum / Decimal::TWO),
|
||||
(l / Decimal::TWO).checked_add(h / Decimal::TWO),
|
||||
h.checked_sub(l)
|
||||
.and_then(|width| l.checked_add(width / Decimal::TWO)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|m| l < *m && *m < h)
|
||||
}
|
||||
|
||||
fn example(&self) -> Option<Decimal> {
|
||||
let candidates = match (self.lo, self.hi) {
|
||||
(Bound::Unbounded, Bound::Unbounded) => vec![Decimal::ZERO],
|
||||
(Bound::Inclusive(l), _) => vec![l],
|
||||
(Bound::Exclusive(l), Bound::Unbounded) => vec![l.floor() + Decimal::ONE],
|
||||
(Bound::Unbounded, Bound::Inclusive(h)) => vec![h],
|
||||
(Bound::Unbounded, Bound::Exclusive(h)) => vec![h.ceil() - Decimal::ONE],
|
||||
(Bound::Unbounded, Bound::Unbounded) => vec![Some(Decimal::ZERO)],
|
||||
(Bound::Inclusive(l), _) => vec![Some(l)],
|
||||
(Bound::Exclusive(l), Bound::Unbounded) => vec![l.floor().checked_add(Decimal::ONE)],
|
||||
(Bound::Unbounded, Bound::Inclusive(h)) => vec![Some(h)],
|
||||
(Bound::Unbounded, Bound::Exclusive(h)) => vec![h.ceil().checked_sub(Decimal::ONE)],
|
||||
(Bound::Exclusive(l), hi) => {
|
||||
let h = hi.value().unwrap_or(l);
|
||||
vec![l.floor() + Decimal::ONE, (l + h) / Decimal::TWO]
|
||||
vec![
|
||||
l.floor().checked_add(Decimal::ONE),
|
||||
Self::midpoint(l, h),
|
||||
Some(h),
|
||||
]
|
||||
}
|
||||
};
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|c| self.contains(*c))
|
||||
.unwrap_or(Decimal::ZERO)
|
||||
candidates.into_iter().flatten().find(|c| self.contains(*c))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +276,7 @@ impl NumberSet {
|
||||
}
|
||||
|
||||
fn example(&self) -> Option<Decimal> {
|
||||
self.intervals.first().map(Interval::example)
|
||||
self.intervals.iter().find_map(Interval::example)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +484,34 @@ impl ValueSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn union_all(sets: &[ValueSet]) -> Self {
|
||||
let mut intervals = Vec::new();
|
||||
let mut finite = BTreeSet::new();
|
||||
let mut cofinite: Option<StringSet> = None;
|
||||
let mut out = Self::empty();
|
||||
for set in sets {
|
||||
intervals.extend(set.numbers.intervals.iter().copied());
|
||||
match &set.strings {
|
||||
StringSet::Finite(values) => finite.extend(values.iter().cloned()),
|
||||
strings => {
|
||||
cofinite = Some(match cofinite {
|
||||
Some(acc) => acc.union(strings),
|
||||
None => strings.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
out.bools |= set.bools;
|
||||
out.null |= set.null;
|
||||
out.other |= set.other;
|
||||
}
|
||||
out.numbers = NumberSet::from_intervals(intervals);
|
||||
out.strings = match cofinite {
|
||||
Some(acc) => acc.union(&StringSet::Finite(finite)),
|
||||
None => StringSet::Finite(finite),
|
||||
};
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn intersect(&self, other: &Self) -> Self {
|
||||
Self {
|
||||
numbers: self.numbers.intersect(&other.numbers),
|
||||
|
||||
@@ -4,7 +4,7 @@ use rust_decimal::Decimal;
|
||||
|
||||
use super::cell::CellConstraint;
|
||||
use super::index::RowIndex;
|
||||
use super::value_set::{Bound, StringSet, ValueSet};
|
||||
use super::value_set::{Bound, Interval, StringSet, ValueSet};
|
||||
use super::verify::VerifyTable;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -48,10 +48,10 @@ impl Point {
|
||||
let fallback = match (interval.lo, interval.hi) {
|
||||
(_, Bound::Inclusive(h)) => Some(h),
|
||||
(Bound::Inclusive(l), _) => Some(l),
|
||||
(Bound::Unbounded, Bound::Exclusive(h)) => Some(h - Decimal::ONE),
|
||||
(Bound::Exclusive(l), Bound::Unbounded) => Some(l + Decimal::ONE),
|
||||
(Bound::Unbounded, Bound::Exclusive(h)) => h.checked_sub(Decimal::ONE),
|
||||
(Bound::Exclusive(l), Bound::Unbounded) => l.checked_add(Decimal::ONE),
|
||||
(Bound::Unbounded, Bound::Unbounded) => Some(Decimal::ZERO),
|
||||
(Bound::Exclusive(l), Bound::Exclusive(h)) => Some((l + h) / Decimal::TWO),
|
||||
(Bound::Exclusive(l), Bound::Exclusive(h)) => Interval::midpoint(l, h),
|
||||
};
|
||||
if let Some(x) = fallback.filter(|x| set.numbers.contains(*x)) {
|
||||
out.push(Point::Number(x));
|
||||
|
||||
@@ -9,7 +9,7 @@ use zen_expression::{Isolate, IsolateError};
|
||||
use super::property_read::ReadFlattener;
|
||||
use super::type_check::TypeCheck;
|
||||
use crate::analysis::nullable::NullableOperand;
|
||||
use crate::analysis::table::VerifyTable;
|
||||
use crate::analysis::table::{HitMode, VerifyInput, VerifyOutput, VerifyTable};
|
||||
use crate::policy::ir::PropertyPath;
|
||||
use crate::policy::queries::dependency::{DataModelPaths, PathPrefix};
|
||||
use crate::policy::queries::scope::VariableTypeScope;
|
||||
@@ -63,6 +63,14 @@ pub type SharedDictionaryTypes = Rc<ahash::HashMap<Arc<str>, VariableType>>;
|
||||
pub type SharedPoisonedPaths = Rc<RefCell<ahash::HashSet<Arc<str>>>>;
|
||||
pub type SharedDeclaredPaths = Rc<DataModelPaths>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableCheck {
|
||||
pub(crate) at: usize,
|
||||
pub(crate) mode: HitMode,
|
||||
pub(crate) inputs: Vec<VerifyInput>,
|
||||
pub(crate) outputs: Vec<VerifyOutput>,
|
||||
}
|
||||
|
||||
pub struct AnalysisContext {
|
||||
scope: VariableType,
|
||||
policy_path: Arc<str>,
|
||||
@@ -70,6 +78,7 @@ pub struct AnalysisContext {
|
||||
reads: Vec<PropertyRead>,
|
||||
writes: Vec<WriteTarget>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
table_checks: Vec<TableCheck>,
|
||||
pass: AnalysisPass,
|
||||
intellisense: SharedIntelliSense,
|
||||
dictionary_types: SharedDictionaryTypes,
|
||||
@@ -97,6 +106,7 @@ impl AnalysisContext {
|
||||
reads: Vec::new(),
|
||||
writes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
table_checks: Vec::new(),
|
||||
pass,
|
||||
intellisense,
|
||||
dictionary_types,
|
||||
@@ -308,24 +318,13 @@ impl AnalysisContext {
|
||||
self.declared_paths.declares(path)
|
||||
}
|
||||
|
||||
pub(super) fn push_table_diagnostics(
|
||||
&mut self,
|
||||
table: &VerifyTable,
|
||||
row_key: impl Fn(usize) -> Arc<str>,
|
||||
) {
|
||||
let policy_path = self.policy_path.clone();
|
||||
let block_id = self.block_id.clone();
|
||||
let diagnostics = table.diagnostics(
|
||||
&mut self.intellisense.borrow_mut(),
|
||||
row_key,
|
||||
|expression_id| match expression_id {
|
||||
Some(id) => {
|
||||
DiagnosticLocation::expression(policy_path.clone(), block_id.clone(), id, None)
|
||||
}
|
||||
None => DiagnosticLocation::block(policy_path.clone(), block_id.clone()),
|
||||
},
|
||||
);
|
||||
self.diagnostics.extend(diagnostics);
|
||||
pub(super) fn defer_table_check(&mut self, table: VerifyTable) {
|
||||
self.table_checks.push(TableCheck {
|
||||
at: self.diagnostics.len(),
|
||||
mode: table.mode,
|
||||
inputs: table.inputs,
|
||||
outputs: table.outputs,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn hint_with_target(
|
||||
@@ -414,6 +413,7 @@ impl AnalysisContext {
|
||||
reads: self.reads,
|
||||
writes: self.writes,
|
||||
diagnostics: self.diagnostics,
|
||||
table_checks: self.table_checks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,16 +494,10 @@ impl AnalysisContext {
|
||||
span: Some(diag.span),
|
||||
target: self.default_target.clone(),
|
||||
};
|
||||
let mut diagnostic = Diagnostic::from_expression(diag, location);
|
||||
NullableOperand::annotate(
|
||||
&mut diagnostic,
|
||||
&mut self.intellisense.borrow_mut(),
|
||||
source,
|
||||
matches!(kind, ExpressionKind::Unary),
|
||||
);
|
||||
self.diagnostics.push(diagnostic);
|
||||
self.diagnostics
|
||||
.push(Diagnostic::from_expression(diag, location));
|
||||
}
|
||||
NullableOperand::fallback_all(
|
||||
NullableOperand::annotate(
|
||||
&mut self.diagnostics[first..],
|
||||
&mut self.intellisense.borrow_mut(),
|
||||
source,
|
||||
@@ -517,6 +511,7 @@ pub struct AnalysisSummary {
|
||||
pub reads: Vec<PropertyRead>,
|
||||
pub writes: Vec<WriteTarget>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub table_checks: Vec<TableCheck>,
|
||||
}
|
||||
|
||||
pub struct ExecutionContext<'a> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use ahash::{HashMap, HashSet};
|
||||
use fixedbitset::FixedBitSet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zen_expression::intellisense::{ArmTest, NumberCover};
|
||||
use zen_expression::intellisense::{ArmTest, IntelliSense, NumberCover};
|
||||
use zen_expression::variable::{Variable, VariableType};
|
||||
use zen_expression::Isolate;
|
||||
use zen_types::decision::{
|
||||
@@ -16,12 +16,12 @@ use crate::analysis::table::{HitMode, TableColumn, VerifyTable};
|
||||
use crate::policy::queries::scope::VariableTypeScope;
|
||||
use crate::workspace::types::{
|
||||
BlockTrace, Cursor, CursorTarget, DecisionTableExtras, Diagnostic, DiagnosticArgs,
|
||||
DiagnosticCode, ExpressionKind,
|
||||
DiagnosticCode, DiagnosticLocation, ExpressionKind,
|
||||
};
|
||||
|
||||
use crate::policy::ArcStrTrim;
|
||||
|
||||
use super::context::{AnalysisContext, ExecutionContext, ExecutionError};
|
||||
use super::context::{AnalysisContext, ExecutionContext, ExecutionError, TableCheck};
|
||||
use super::{
|
||||
Block, BlockKind, BlockReadPlan, CellReads, ConditionalReads, ExpressionLocation, ParseContext,
|
||||
ReadFlattenFn, WriteSite, WriteTarget,
|
||||
@@ -478,7 +478,7 @@ impl DecisionTableIr {
|
||||
.collect(),
|
||||
rules: &self.rules,
|
||||
};
|
||||
cx.push_table_diagnostics(&table, |row| Self::row_key(&self.rules[row], row));
|
||||
cx.defer_table_check(table);
|
||||
}
|
||||
|
||||
for col in &self.outputs {
|
||||
@@ -703,6 +703,31 @@ impl DecisionTableIr {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verify(
|
||||
&self,
|
||||
check: &TableCheck,
|
||||
is: &mut IntelliSense,
|
||||
policy_path: &Arc<str>,
|
||||
block_id: &Arc<str>,
|
||||
) -> Vec<Diagnostic> {
|
||||
let table = VerifyTable {
|
||||
mode: check.mode,
|
||||
inputs: check.inputs.clone(),
|
||||
outputs: check.outputs.clone(),
|
||||
rules: &self.rules,
|
||||
};
|
||||
table.diagnostics(
|
||||
is,
|
||||
|row| Self::row_key(&self.rules[row], row),
|
||||
|expression_id| match expression_id {
|
||||
Some(id) => {
|
||||
DiagnosticLocation::expression(policy_path.clone(), block_id.clone(), id, None)
|
||||
}
|
||||
None => DiagnosticLocation::block(policy_path.clone(), block_id.clone()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn row_key(rule: &HashMap<Arc<str>, Arc<str>>, row: usize) -> Arc<str> {
|
||||
rule.get(ROW_ID_KEY)
|
||||
.cloned()
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(crate) use context::IntelliSenseSource;
|
||||
pub use context::{
|
||||
AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation,
|
||||
InstanceSource, PropertyRead, SharedDeclaredPaths, SharedDictionaryTypes, SharedIntelliSense,
|
||||
SharedPoisonedPaths, WriteTarget,
|
||||
SharedPoisonedPaths, TableCheck, WriteTarget,
|
||||
};
|
||||
pub use decision_table::{DecisionTableDoc, DecisionTableIr, DeclaredType};
|
||||
pub(crate) use decision_table::{DictionaryCandidate, TableSelection, ROW_ID_KEY};
|
||||
|
||||
@@ -117,10 +117,35 @@ impl AstOps {
|
||||
}
|
||||
|
||||
fn chars_at(source: &str, span: Span) -> impl Iterator<Item = char> + '_ {
|
||||
source
|
||||
.chars()
|
||||
.skip(span.0 as usize)
|
||||
.take((span.1 as usize).saturating_sub(span.0 as usize))
|
||||
Self::text(source, span).unwrap_or_default().chars()
|
||||
}
|
||||
|
||||
pub(crate) fn text(source: &str, span: Span) -> Option<&str> {
|
||||
source.get(span.0 as usize..span.1 as usize)
|
||||
}
|
||||
|
||||
pub(crate) fn splice(source: &str, edits: &[(Span, &str)]) -> Option<String> {
|
||||
let word = |c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '#');
|
||||
let mut sorted: Vec<&(Span, &str)> = edits.iter().collect();
|
||||
sorted.sort_by_key(|(range, _)| *range);
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut push = |piece: &str| {
|
||||
if out.chars().next_back().is_some_and(word) && piece.chars().next().is_some_and(word) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(piece);
|
||||
};
|
||||
let mut cursor = 0u32;
|
||||
for (range, with) in sorted {
|
||||
if range.0 < cursor || range.1 < range.0 {
|
||||
return None;
|
||||
}
|
||||
push(Self::text(source, (cursor, range.0))?);
|
||||
push(with);
|
||||
cursor = range.1;
|
||||
}
|
||||
push(source.get(cursor as usize..)?);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use zen_expression::intellisense::AstMetadata;
|
||||
use zen_expression::intellisense::{AstMetadata, IntelliSense};
|
||||
use zen_expression::lexer::Operator;
|
||||
use zen_expression::parser::{Associativity, Node, ParserOperator};
|
||||
|
||||
use crate::analysis::proof::{FixEdit, FixProof};
|
||||
use crate::workspace::types::{
|
||||
Diagnostic, DiagnosticArgs, DiagnosticCode, DiagnosticLocation, ExpressionKind, Span,
|
||||
};
|
||||
@@ -203,28 +204,34 @@ impl RedundantParentheses {
|
||||
|
||||
impl RedundantParentheses {
|
||||
pub(crate) fn fix_args(
|
||||
is: &mut IntelliSense,
|
||||
source: &str,
|
||||
findings: &[(Option<Span>, Option<Span>)],
|
||||
mut shape: impl FnMut(&str) -> Option<String>,
|
||||
) -> Vec<DiagnosticArgs> {
|
||||
let pairs: Vec<Option<(Span, Span)>> = findings
|
||||
let edits: Vec<Option<FixEdit>> = findings
|
||||
.iter()
|
||||
.map(|(outer, inner)| Some(((*outer)?, (*inner)?)))
|
||||
.map(|(outer, inner)| Some(FixEdit::unwrap((*outer)?, (*inner)?, None)))
|
||||
.collect();
|
||||
let Some(expected) = shape(source) else {
|
||||
return vec![DiagnosticArgs::new(); findings.len()];
|
||||
};
|
||||
let mut verified =
|
||||
|fixed: String| (shape(&fixed).as_deref() == Some(expected.as_str())).then_some(fixed);
|
||||
let all: Vec<(Span, Span)> = pairs.iter().flatten().copied().collect();
|
||||
let fix_all = (all.len() > 1)
|
||||
.then(|| Self::strip(source, &all))
|
||||
.and_then(&mut verified);
|
||||
pairs
|
||||
let candidates: Vec<FixEdit> = edits.iter().flatten().cloned().collect();
|
||||
let proven = FixProof::proven(is, source, false, &candidates);
|
||||
let accepted: Vec<&FixEdit> = candidates
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
.zip(&proven)
|
||||
.filter_map(|(edit, proven)| proven.then_some(edit))
|
||||
.collect();
|
||||
let fix_all = (accepted.len() > 1)
|
||||
.then(|| FixProof::holds(is, source, false, &accepted))
|
||||
.flatten();
|
||||
let mut proven = proven.into_iter();
|
||||
edits
|
||||
.iter()
|
||||
.map(|edit| {
|
||||
let mut args = DiagnosticArgs::new();
|
||||
if let Some(fixed) = pair.and_then(|pair| verified(Self::strip(source, &[pair]))) {
|
||||
let fixed = edit
|
||||
.as_ref()
|
||||
.filter(|_| proven.next() == Some(true))
|
||||
.and_then(|edit| edit.apply(source));
|
||||
if let Some(fixed) = fixed {
|
||||
args.insert("fixOriginal", source.to_string());
|
||||
args.insert("fixSource", fixed);
|
||||
if let Some(all) = &fix_all {
|
||||
@@ -235,83 +242,6 @@ impl RedundantParentheses {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn tree_shape(debug: &str) -> String {
|
||||
const WRAPPER: &str = "Parenthesized(";
|
||||
let chars: Vec<char> = debug.chars().collect();
|
||||
let wrapper: Vec<char> = WRAPPER.chars().collect();
|
||||
let mut drop_close: Vec<usize> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut quoted = false;
|
||||
let mut out = String::with_capacity(debug.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if quoted {
|
||||
out.push(c);
|
||||
if c == '\\' && i + 1 < chars.len() {
|
||||
out.push(chars[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
quoted = c != '"';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
quoted = true;
|
||||
out.push(c);
|
||||
} else if chars[i..].starts_with(&wrapper) {
|
||||
depth += 1;
|
||||
drop_close.push(depth);
|
||||
i += wrapper.len();
|
||||
continue;
|
||||
} else if c == '(' {
|
||||
depth += 1;
|
||||
out.push(c);
|
||||
} else if c == ')' {
|
||||
if drop_close.last() == Some(&depth) {
|
||||
drop_close.pop();
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip(source: &str, pairs: &[(Span, Span)]) -> String {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let mut removed = vec![false; chars.len()];
|
||||
for (outer, inner) in pairs {
|
||||
for idx in
|
||||
(outer.0 as usize..inner.0 as usize).chain(inner.1 as usize..outer.1 as usize)
|
||||
{
|
||||
if let Some(slot) = removed.get_mut(idx) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let word = |c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '#');
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut gap = false;
|
||||
for (idx, c) in chars.iter().enumerate() {
|
||||
if removed[idx] {
|
||||
gap = true;
|
||||
continue;
|
||||
}
|
||||
if gap && out.chars().last().is_some_and(word) && word(*c) {
|
||||
out.push(' ');
|
||||
}
|
||||
gap = false;
|
||||
out.push(*c);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl LintRule for RedundantParentheses {
|
||||
@@ -326,11 +256,11 @@ impl LintRule for RedundantParentheses {
|
||||
RedundantParentheses::scan(root, metadata)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let fixes = Self::fix_args(&expression.source, &findings, |source| {
|
||||
cx.with_ast(source, expression.kind, |root, _| {
|
||||
Self::tree_shape(&format!("{root:?}"))
|
||||
})
|
||||
});
|
||||
let fixes = Self::fix_args(
|
||||
&mut cx.db.intellisense().borrow_mut(),
|
||||
&expression.source,
|
||||
&findings,
|
||||
);
|
||||
for ((span, inner_span), args) in findings.into_iter().zip(fixes) {
|
||||
let message = match inner_span {
|
||||
Some(inner) => format!(
|
||||
|
||||
@@ -9,7 +9,7 @@ use zen_expression::variable::VariableType;
|
||||
|
||||
use crate::policy::blocks::{
|
||||
AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDeclaredPaths,
|
||||
SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths, WriteTarget,
|
||||
SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths, TableCheck, WriteTarget,
|
||||
};
|
||||
use crate::policy::ir::{DataModelIr, ParsedPolicy, PropertyPath, PropertyTypeIr};
|
||||
use crate::policy::queries::path::{PathClassifier, PathRoot};
|
||||
@@ -142,7 +142,9 @@ impl EnrichedState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuleEnrichedAnalysis {
|
||||
pub policy_path: Arc<str>,
|
||||
pub block_id: Arc<str>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub table_checks: Vec<TableCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -801,7 +803,9 @@ impl Snapshot {
|
||||
|
||||
per_rule.push(RuleEnrichedAnalysis {
|
||||
policy_path: policy_path.clone(),
|
||||
block_id: key.block_id.clone(),
|
||||
diagnostics: summary.diagnostics,
|
||||
table_checks: summary.table_checks,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::sync::Arc;
|
||||
|
||||
use ahash::{HashMap, HashMapExt, HashSet};
|
||||
|
||||
use crate::policy::blocks::BlockKind;
|
||||
use crate::policy::ir::PropertyTypeIr;
|
||||
use crate::policy::linter::Linter;
|
||||
use crate::policy::queries::dependency::WriteScope;
|
||||
use crate::policy::queries::dependency::{RuleEnrichedAnalysis, WriteScope};
|
||||
use crate::policy::queries::path::PathRoot;
|
||||
use crate::workspace::db::{Db, Unit};
|
||||
use crate::workspace::types::{BlockRef, Diagnostic, DiagnosticCode, DiagnosticLocation};
|
||||
@@ -50,13 +51,13 @@ impl Db {
|
||||
.filter(|d| d.is_in(path))
|
||||
.cloned(),
|
||||
);
|
||||
out.extend(
|
||||
enriched
|
||||
.per_rule
|
||||
.iter()
|
||||
.filter(|rule| rule.policy_path == *path)
|
||||
.flat_map(|rule| rule.diagnostics.iter().cloned()),
|
||||
);
|
||||
for rule in enriched
|
||||
.per_rule
|
||||
.iter()
|
||||
.filter(|rule| rule.policy_path == *path)
|
||||
{
|
||||
out.extend(self.rule_diagnostics(rule));
|
||||
}
|
||||
|
||||
out.extend(self.import_diagnostics(path));
|
||||
|
||||
@@ -71,6 +72,34 @@ impl Db {
|
||||
out
|
||||
}
|
||||
|
||||
fn rule_diagnostics(&self, rule: &RuleEnrichedAnalysis) -> Vec<Diagnostic> {
|
||||
if rule.table_checks.is_empty() {
|
||||
return rule.diagnostics.clone();
|
||||
}
|
||||
let block = self.block_ir(&BlockRef {
|
||||
policy_path: rule.policy_path.clone(),
|
||||
block_id: rule.block_id.clone(),
|
||||
});
|
||||
let Some(BlockKind::DecisionTable(table)) = block.as_ref().map(|block| &block.kind) else {
|
||||
return rule.diagnostics.clone();
|
||||
};
|
||||
let intellisense = self.intellisense();
|
||||
let mut out = Vec::with_capacity(rule.diagnostics.len());
|
||||
let mut cursor = 0;
|
||||
for check in &rule.table_checks {
|
||||
out.extend(rule.diagnostics[cursor..check.at].iter().cloned());
|
||||
out.extend(table.verify(
|
||||
check,
|
||||
&mut intellisense.borrow_mut(),
|
||||
&rule.policy_path,
|
||||
&rule.block_id,
|
||||
));
|
||||
cursor = check.at;
|
||||
}
|
||||
out.extend(rule.diagnostics[cursor..].iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn locate_nullable_sources(&self, path: &Arc<str>, out: &mut [Diagnostic]) {
|
||||
if !out.iter().any(|d| d.args.contains_key("nullablePath")) {
|
||||
return;
|
||||
@@ -749,3 +778,65 @@ impl Db {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::workspace::types::{DiagnosticCode, Severity};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
#[test]
|
||||
fn evaluation_diagnostics_skip_table_verification() {
|
||||
let policy = |value: &str| {
|
||||
json!({ "blocks": [
|
||||
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
||||
"name": "applicant",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
||||
]
|
||||
} } },
|
||||
{ "id": "dt", "type": "decisionTable", "props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "i0", "name": "Age", "field": "applicant.age" } ],
|
||||
"outputs": [ { "id": "o0", "name": "Rate", "field": "applicant.rate" } ],
|
||||
"rules": [
|
||||
{ "_id": "r1", "i0": "< 18", "o0": "1" },
|
||||
{ "_id": "r2", "i0": "< 10", "o0": "2" }
|
||||
]
|
||||
} } },
|
||||
{ "id": "calc", "type": "expression", "props": { "data": { "key": "applicant.total", "value": value } } }
|
||||
] })
|
||||
};
|
||||
let table_codes = [
|
||||
DiagnosticCode::MissingCases,
|
||||
DiagnosticCode::UnreachableRule,
|
||||
];
|
||||
for (value, errors) in [("applicant.age + 1", 0), ("applicant.missing > 50", 1)] {
|
||||
let mut ws = Workspace::new();
|
||||
ws.set_policy("p", serde_json::from_value(policy(value)).expect("policy"));
|
||||
let editor = ws.diagnostics("p");
|
||||
assert_eq!(
|
||||
editor
|
||||
.iter()
|
||||
.filter(|d| table_codes.contains(&d.code))
|
||||
.count(),
|
||||
2,
|
||||
"{editor:?}"
|
||||
);
|
||||
let evaluation = ws.evaluation_diagnostics("p");
|
||||
assert!(
|
||||
evaluation.iter().all(|d| !table_codes.contains(&d.code)),
|
||||
"{evaluation:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.count(),
|
||||
errors,
|
||||
"{evaluation:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,8 +1169,13 @@ impl<'a> GraphAnalyzer<'a> {
|
||||
return None;
|
||||
}
|
||||
let mut written: Vec<Arc<str>> = Vec::new();
|
||||
let mut seen: HashSet<Arc<str>> = HashSet::default();
|
||||
for (pred, _) in incoming {
|
||||
written.extend(after.get(pred).cloned().flatten()?);
|
||||
for path in after.get(pred)?.as_ref()? {
|
||||
if seen.insert(path.clone()) {
|
||||
written.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(written)
|
||||
}
|
||||
@@ -1280,7 +1285,7 @@ impl<'a> GraphAnalyzer<'a> {
|
||||
content: &DecisionTableContent,
|
||||
field: Option<&str>,
|
||||
) -> Option<NumberSet> {
|
||||
if content.transform_attributes.input_field.is_some() {
|
||||
if !self.preserved_input(content, field) {
|
||||
return None;
|
||||
}
|
||||
let field = field?.trim();
|
||||
@@ -1647,13 +1652,11 @@ impl<'a> GraphAnalyzer<'a> {
|
||||
RedundantParentheses::scan(root, metadata)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let fixes = RedundantParentheses::fix_args(&site.source, &findings, |source| {
|
||||
intellisense
|
||||
.borrow_mut()
|
||||
.with_ast(source, false, |root, _| {
|
||||
RedundantParentheses::tree_shape(&format!("{root:?}"))
|
||||
})
|
||||
});
|
||||
let fixes = RedundantParentheses::fix_args(
|
||||
&mut intellisense.borrow_mut(),
|
||||
&site.source,
|
||||
&findings,
|
||||
);
|
||||
for ((span, inner_span), args) in findings.into_iter().zip(fixes) {
|
||||
let message = match inner_span {
|
||||
Some(inner) => format!(
|
||||
@@ -2076,16 +2079,10 @@ impl<'a> GraphAnalyzer<'a> {
|
||||
span: Some(diagnostic.span),
|
||||
target: target.clone(),
|
||||
};
|
||||
let mut diagnostic = Diagnostic::from_expression(diagnostic, location);
|
||||
NullableOperand::annotate(
|
||||
&mut diagnostic,
|
||||
&mut intellisense.borrow_mut(),
|
||||
source,
|
||||
matches!(kind, ExpressionKind::Unary),
|
||||
);
|
||||
self.diagnostics.push(diagnostic);
|
||||
self.diagnostics
|
||||
.push(Diagnostic::from_expression(diagnostic, location));
|
||||
}
|
||||
NullableOperand::fallback_all(
|
||||
NullableOperand::annotate(
|
||||
&mut self.diagnostics[first..],
|
||||
&mut intellisense.borrow_mut(),
|
||||
source,
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use zen_engine::loader::MemoryLoader;
|
||||
use zen_engine::model::{DecisionContent, PolicyContent};
|
||||
use zen_engine::policy::{Diagnostic, DiagnosticCode, PolicyWorkspace, Workspace};
|
||||
use zen_engine::DecisionEngine;
|
||||
|
||||
fn with_code(diagnostics: &[Diagnostic], code: DiagnosticCode) -> Vec<Diagnostic> {
|
||||
diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.code == code)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn arg(diagnostic: &Diagnostic, key: &str) -> Option<String> {
|
||||
diagnostic.args.get(key).cloned()
|
||||
}
|
||||
|
||||
fn applicant_model() -> Value {
|
||||
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
||||
"name": "applicant",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false },
|
||||
{ "id": "p2", "name": "target", "type": "number", "array": false, "optional": true },
|
||||
{ "id": "p3", "name": "vip", "type": "boolean", "array": false, "optional": false }
|
||||
]
|
||||
} } })
|
||||
}
|
||||
|
||||
fn policy_diagnostics(blocks: Vec<Value>) -> Vec<Diagnostic> {
|
||||
let mut all = vec![applicant_model()];
|
||||
all.extend(blocks);
|
||||
let mut ws = PolicyWorkspace::new();
|
||||
ws.set_policy(
|
||||
"p",
|
||||
serde_json::from_value(json!({ "blocks": all })).expect("policy"),
|
||||
);
|
||||
ws.diagnostics("p")
|
||||
}
|
||||
|
||||
fn policy_expression(value: &str) -> Vec<Diagnostic> {
|
||||
policy_diagnostics(vec![
|
||||
json!({ "id": "calc", "type": "expression", "props": { "data": { "key": "applicant.total", "value": value } } }),
|
||||
])
|
||||
}
|
||||
|
||||
fn graph_expression(value: &str) -> Vec<Diagnostic> {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "amount": { "type": "number" }, "target": { "type": "number" } },
|
||||
"required": ["amount"]
|
||||
});
|
||||
let content: DecisionContent = serde_json::from_value(json!({
|
||||
"nodes": [
|
||||
{ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": schema.to_string() } },
|
||||
{ "id": "calc", "name": "calc", "type": "expressionNode", "content": {
|
||||
"expressions": [ { "id": "x", "key": "total", "value": value } ],
|
||||
"passThrough": true
|
||||
} },
|
||||
{ "id": "out", "name": "out", "type": "outputNode", "content": {} }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e1", "sourceId": "in", "targetId": "calc" },
|
||||
{ "id": "e2", "sourceId": "calc", "targetId": "out" }
|
||||
]
|
||||
}))
|
||||
.expect("graph");
|
||||
let mut ws = Workspace::new();
|
||||
ws.set_document("g", content);
|
||||
ws.diagnostics("g")
|
||||
}
|
||||
|
||||
fn cell_table(field: &str, cell: &str) -> Value {
|
||||
json!({ "id": "dt", "type": "decisionTable", "props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "i0", "name": "In", "field": field } ],
|
||||
"outputs": [ { "id": "o0", "name": "Rate", "field": "applicant.rate" } ],
|
||||
"rules": [
|
||||
{ "_id": "r1", "i0": cell, "o0": "1" },
|
||||
{ "_id": "r2", "i0": "", "o0": "2" }
|
||||
]
|
||||
} } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_fallback_fix_keeps_boolean_semantics() {
|
||||
for cell in [
|
||||
"applicant.vip ?? false",
|
||||
"$ ?? false",
|
||||
"(applicant.vip ?? true)",
|
||||
] {
|
||||
let found = with_code(
|
||||
&policy_diagnostics(vec![cell_table("applicant.vip", cell)]),
|
||||
DiagnosticCode::RedundantNullish,
|
||||
);
|
||||
assert_eq!(found.len(), 1, "{cell}: {found:?}");
|
||||
assert_eq!(arg(&found[0], "fixSource"), None, "{cell}: {found:?}");
|
||||
}
|
||||
|
||||
let found = with_code(
|
||||
&policy_diagnostics(vec![cell_table(
|
||||
"applicant.age",
|
||||
"(applicant.age ?? 0) + 1",
|
||||
)]),
|
||||
DiagnosticCode::RedundantNullish,
|
||||
);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(
|
||||
arg(&found[0], "fixSource").as_deref(),
|
||||
Some("applicant.age + 1")
|
||||
);
|
||||
|
||||
let found = with_code(
|
||||
&policy_diagnostics(vec![cell_table(
|
||||
"applicant.age",
|
||||
"(applicant.age ?? 0) + (applicant.age ?? 1)",
|
||||
)]),
|
||||
DiagnosticCode::RedundantNullish,
|
||||
);
|
||||
assert_eq!(found.len(), 2, "{found:?}");
|
||||
for d in &found {
|
||||
assert_eq!(arg(d, "fixSource"), None, "{d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_splice_by_byte_offsets() {
|
||||
for text in ["café", "é🎉"] {
|
||||
let source = format!("\"{text}\" != \"x\" and applicant.target > 0");
|
||||
let found = with_code(&policy_expression(&source), DiagnosticCode::TypeMismatch);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(
|
||||
arg(&found[0], "fixSource"),
|
||||
Some(format!(
|
||||
"\"{text}\" != \"x\" and (applicant.target ?? 0) > 0"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
arg(&found[0], "fixOperand").as_deref(),
|
||||
Some("applicant.target")
|
||||
);
|
||||
|
||||
let source = format!("\"{text}\" != \"x\" and (applicant.age ?? 0) > 1");
|
||||
let found = with_code(
|
||||
&policy_expression(&source),
|
||||
DiagnosticCode::RedundantNullish,
|
||||
);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(
|
||||
arg(&found[0], "fixSource"),
|
||||
Some(format!("\"{text}\" != \"x\" and applicant.age > 1"))
|
||||
);
|
||||
assert_eq!(arg(&found[0], "fixFallback").as_deref(), Some("0"));
|
||||
|
||||
let source = format!("\"{text}\" != \"x\" and (applicant.age) > 1");
|
||||
let found = with_code(
|
||||
&policy_expression(&source),
|
||||
DiagnosticCode::RedundantParentheses,
|
||||
);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(
|
||||
arg(&found[0], "fixSource"),
|
||||
Some(format!("\"{text}\" != \"x\" and applicant.age > 1"))
|
||||
);
|
||||
assert_eq!(
|
||||
found[0].message,
|
||||
"unnecessary parentheses around 'applicant.age'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_fix_proofs_scale_with_expression_length() {
|
||||
let source = vec!["(amount ?? 0) + (1)"; 1500].join(" + ");
|
||||
let started = Instant::now();
|
||||
let diagnostics = graph_expression(&source);
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!("{} bytes in {elapsed:?}", source.len());
|
||||
|
||||
for (code, all, first) in [
|
||||
(
|
||||
DiagnosticCode::RedundantNullish,
|
||||
vec!["amount + (1)"; 1500].join(" + "),
|
||||
"amount + (1) + (amount ?? 0) + (1)",
|
||||
),
|
||||
(
|
||||
DiagnosticCode::RedundantParentheses,
|
||||
vec!["(amount ?? 0) + 1"; 1500].join(" + "),
|
||||
"(amount ?? 0) + 1 + (amount ?? 0) + (1)",
|
||||
),
|
||||
] {
|
||||
let found = with_code(&diagnostics, code);
|
||||
assert_eq!(found.len(), 1500);
|
||||
for d in &found {
|
||||
assert_eq!(arg(d, "fixOriginal").as_deref(), Some(source.as_str()));
|
||||
assert_eq!(arg(d, "fixAll").as_deref(), Some(all.as_str()));
|
||||
}
|
||||
let fixed = arg(&found[0], "fixSource").expect("fix");
|
||||
assert!(fixed.starts_with(first), "{}", &fixed[..60]);
|
||||
assert_eq!(
|
||||
fixed.len(),
|
||||
source.len() - (source.len() - all.len()) / 1500
|
||||
);
|
||||
}
|
||||
assert!(elapsed < Duration::from_secs(10), "{elapsed:?}");
|
||||
|
||||
let source = vec!["target * 2"; 2000].join(" + ");
|
||||
let started = Instant::now();
|
||||
let found = with_code(&graph_expression(&source), DiagnosticCode::TypeMismatch);
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(found.len(), 2000);
|
||||
assert!(found.iter().all(|d| d.args.contains_key("fixSource")));
|
||||
let fixed = arg(&found[0], "fixSource").expect("fix");
|
||||
assert!(
|
||||
fixed.starts_with("(target ?? 0) * 2 + target * 2"),
|
||||
"{}",
|
||||
&fixed[..60]
|
||||
);
|
||||
assert!(elapsed < Duration::from_secs(10), "{elapsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_fallbacks_are_proven_separately() {
|
||||
let found = with_code(
|
||||
&graph_expression("-(amount ?? 0 ?? 1)"),
|
||||
DiagnosticCode::RedundantNullish,
|
||||
);
|
||||
let mut fixes: Vec<String> = found
|
||||
.iter()
|
||||
.map(|d| arg(d, "fixSource").expect("fix"))
|
||||
.collect();
|
||||
fixes.sort();
|
||||
assert_eq!(fixes, vec!["-(amount ?? 0)", "-(amount ?? 1)"]);
|
||||
for d in &found {
|
||||
assert_eq!(arg(d, "fixAll").as_deref(), Some("-(amount)"));
|
||||
}
|
||||
}
|
||||
|
||||
fn number_schema() -> String {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"applicant": {
|
||||
"type": "object",
|
||||
"properties": { "age": { "type": "number", "minimum": 0, "maximum": 120 } },
|
||||
"required": ["age"]
|
||||
}
|
||||
},
|
||||
"required": ["applicant"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn age_table() -> Value {
|
||||
json!({ "id": "dt", "name": "dt", "type": "decisionTableNode", "content": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "i0", "name": "Age", "field": "applicant.age" } ],
|
||||
"outputs": [ { "id": "o0", "name": "Rate", "field": "rate" } ],
|
||||
"rules": [ { "_id": "r1", "i0": "<= 120", "o0": "1" } ]
|
||||
} })
|
||||
}
|
||||
|
||||
fn expression_node(id: &str, key: &str, value: &str) -> Value {
|
||||
json!({ "id": id, "name": id, "type": "expressionNode", "content": {
|
||||
"expressions": [ { "id": format!("{id}-x"), "key": key, "value": value } ],
|
||||
"passThrough": true
|
||||
} })
|
||||
}
|
||||
|
||||
fn graph_diagnostics(nodes: Vec<Value>, edges: &[(&str, &str)]) -> Vec<Diagnostic> {
|
||||
let mut all = vec![
|
||||
json!({ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": number_schema() } }),
|
||||
];
|
||||
all.extend(nodes);
|
||||
all.push(json!({ "id": "out", "name": "out", "type": "outputNode", "content": {} }));
|
||||
let edges: Vec<Value> = edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (a, b))| json!({ "id": format!("e{i}"), "sourceId": a, "targetId": b, "sourceHandle": null }))
|
||||
.collect();
|
||||
let mut ws = Workspace::new();
|
||||
ws.set_document(
|
||||
"g",
|
||||
serde_json::from_value(json!({ "nodes": all, "edges": edges })).expect("graph"),
|
||||
);
|
||||
ws.diagnostics("g")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_ranges_ignore_rewritten_fields() {
|
||||
let direct = graph_diagnostics(vec![age_table()], &[("in", "dt"), ("dt", "out")]);
|
||||
assert_eq!(
|
||||
with_code(&direct, DiagnosticCode::CellCoversDomain).len(),
|
||||
1,
|
||||
"{direct:?}"
|
||||
);
|
||||
|
||||
let rewritten = graph_diagnostics(
|
||||
vec![
|
||||
expression_node("calc", "applicant.age", "applicant.age + 1000"),
|
||||
age_table(),
|
||||
],
|
||||
&[("in", "calc"), ("calc", "dt"), ("dt", "out")],
|
||||
);
|
||||
assert!(
|
||||
with_code(&rewritten, DiagnosticCode::CellCoversDomain).is_empty(),
|
||||
"{rewritten:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn diamonds(count: usize, rewrite: bool) -> Vec<Diagnostic> {
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges: Vec<(String, String)> = Vec::new();
|
||||
let mut previous = "in".to_string();
|
||||
for i in 0..count {
|
||||
let (a, b, join) = (format!("a{i}"), format!("b{i}"), format!("j{i}"));
|
||||
let key = if rewrite && i == 0 {
|
||||
"applicant.age"
|
||||
} else {
|
||||
"applicant.seen"
|
||||
};
|
||||
nodes.push(expression_node(&a, key, "applicant.age + 1"));
|
||||
nodes.push(expression_node(&b, "applicant.seen", "applicant.age"));
|
||||
nodes.push(expression_node(&join, "applicant.seen", "applicant.age"));
|
||||
edges.push((previous.clone(), a.clone()));
|
||||
edges.push((previous.clone(), b.clone()));
|
||||
edges.push((a, join.clone()));
|
||||
edges.push((b, join.clone()));
|
||||
previous = join;
|
||||
}
|
||||
nodes.push(age_table());
|
||||
edges.push((previous, "dt".to_string()));
|
||||
edges.push(("dt".to_string(), "out".to_string()));
|
||||
let edges: Vec<(&str, &str)> = edges
|
||||
.iter()
|
||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||
.collect();
|
||||
graph_diagnostics(nodes, &edges)
|
||||
}
|
||||
|
||||
fn summary(diagnostics: &[Diagnostic]) -> Vec<String> {
|
||||
let mut out: Vec<String> = diagnostics
|
||||
.iter()
|
||||
.map(|d| format!("{:?} {:?} {}", d.code, d.severity, d.message))
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_diamonds_stay_linear() {
|
||||
let started = Instant::now();
|
||||
let found = diamonds(30, false);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
let covers = vec![
|
||||
"CellCoversDomain Hint this condition accepts every possible Age value, so the cell can be empty"
|
||||
.to_string(),
|
||||
];
|
||||
assert_eq!(summary(&found), covers);
|
||||
assert_eq!(summary(&diamonds(2, false)), covers);
|
||||
assert_eq!(
|
||||
summary(&diamonds(2, true)),
|
||||
vec!["MissingCases Hint no row matches 1 input case: Age > 120".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
fn policy_content(blocks: Vec<Value>) -> DecisionContent {
|
||||
let mut all = vec![applicant_model()];
|
||||
all.extend(blocks);
|
||||
let policy: zen_engine::policy::PolicyDocument =
|
||||
serde_json::from_value(json!({ "blocks": all })).expect("policy");
|
||||
DecisionContent::Policy(PolicyContent(Arc::new(policy)))
|
||||
}
|
||||
|
||||
fn gapped_table() -> Value {
|
||||
json!({ "id": "dt", "type": "decisionTable", "props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "i0", "name": "Age", "field": "applicant.age" } ],
|
||||
"outputs": [ { "id": "o0", "name": "Rate", "field": "applicant.rate" } ],
|
||||
"rules": [
|
||||
{ "_id": "r1", "i0": "< 18", "o0": "1" },
|
||||
{ "_id": "r2", "i0": "< 10", "o0": "2" }
|
||||
]
|
||||
} } })
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_skips_table_checks_but_keeps_errors() {
|
||||
let table_codes = |diagnostics: &[Diagnostic]| {
|
||||
diagnostics
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.code,
|
||||
DiagnosticCode::MissingCases | DiagnosticCode::UnreachableRule
|
||||
)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert_eq!(table_codes(&policy_diagnostics(vec![gapped_table()])), 2);
|
||||
|
||||
let loader = Arc::new(MemoryLoader::default());
|
||||
loader.add("ok", policy_content(vec![gapped_table()]));
|
||||
loader.add(
|
||||
"broken",
|
||||
policy_content(vec![
|
||||
gapped_table(),
|
||||
json!({ "id": "calc", "type": "expression", "props": { "data": { "key": "applicant.total", "value": "applicant.missing > 50" } } }),
|
||||
]),
|
||||
);
|
||||
let engine = DecisionEngine::default().with_loader(loader);
|
||||
|
||||
let result = engine
|
||||
.evaluate("ok", json!({ "applicant": { "age": 5 } }).into())
|
||||
.await
|
||||
.expect("evaluate");
|
||||
let output: Value = result.result.into();
|
||||
assert_eq!(output.pointer("/applicant/rate"), Some(&json!(1)));
|
||||
|
||||
let result = engine
|
||||
.evaluate("broken", json!({ "applicant": { "age": 5 } }).into())
|
||||
.await;
|
||||
assert!(
|
||||
format!("{result:?}").contains("CompilationErrors"),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
@@ -1595,3 +1595,270 @@ fn covered_rows_with_the_same_result_are_redundant_hints() {
|
||||
assert!(d.message.contains("redundant"), "{}", d.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_value_lists_and_or_chains_fit_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let list: Vec<String> = (0..1500).map(|i| i.to_string()).collect();
|
||||
let chain: Vec<String> = (0..1500).map(|i| format!("$ == {i}")).collect();
|
||||
for cell in [list.join(", "), chain.join(" or ")] {
|
||||
let table = Table {
|
||||
hit: "first",
|
||||
inputs: &["applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: leak_rows(vec![
|
||||
("r1".to_string(), vec![cell.clone()], vec!["1".to_string()]),
|
||||
(
|
||||
"r2".to_string(),
|
||||
vec!["1499".to_string()],
|
||||
vec!["2".to_string()],
|
||||
),
|
||||
]),
|
||||
};
|
||||
table.assert_both(&[
|
||||
"UnreachableRule r2 coveredByIds=r1 example={\"applicant\":{\"age\":1499}}",
|
||||
]);
|
||||
}
|
||||
})
|
||||
.expect("thread")
|
||||
.join()
|
||||
.expect("no stack overflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_decimal_bounds_do_not_overflow() {
|
||||
for (cells, gap) in [
|
||||
(["<= 79228162514264337593543950335", "> 0"], None),
|
||||
(
|
||||
["< -79228162514264337593543950335", "> 0"],
|
||||
Some("[-79228162514264337593543950335..0]"),
|
||||
),
|
||||
(
|
||||
[
|
||||
"(79228162514264337593543950333..79228162514264337593543950335]",
|
||||
"< 0",
|
||||
],
|
||||
Some("[0..79228162514264337593543950333], > 79228162514264337593543950335"),
|
||||
),
|
||||
(
|
||||
[
|
||||
"> -79228162514264337593543950335 and < -79228162514264337593543950334",
|
||||
"> 0",
|
||||
],
|
||||
Some("<= -79228162514264337593543950335, [-79228162514264337593543950334..0]"),
|
||||
),
|
||||
] {
|
||||
let table = Table {
|
||||
hit: "first",
|
||||
inputs: &["applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: leak_rows(vec![
|
||||
(
|
||||
"r1".to_string(),
|
||||
vec![cells[0].to_string()],
|
||||
vec!["1".to_string()],
|
||||
),
|
||||
(
|
||||
"r2".to_string(),
|
||||
vec![cells[1].to_string()],
|
||||
vec!["2".to_string()],
|
||||
),
|
||||
]),
|
||||
};
|
||||
for gaps in [
|
||||
Table::gaps(table.policy_diagnostics()),
|
||||
Table::gaps(table.graph_diagnostics()),
|
||||
] {
|
||||
let found = gaps.map(|gaps| gaps.cases[0]["cells"]["i0"].clone());
|
||||
assert_eq!(found, gap.map(|g| json!(g)), "{cells:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaced_random_calls_are_never_equal() {
|
||||
let table = Table {
|
||||
hit: "first",
|
||||
inputs: &["", "applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: &[
|
||||
("r1", &["rand (10) > 5", ""], &["1"]),
|
||||
("r2", &["rand (10) > 5", ""], &["1"]),
|
||||
],
|
||||
};
|
||||
table.assert_both(&[]);
|
||||
table.assert_compressed(None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_strings_keep_their_quotes_and_backslashes() {
|
||||
let table = Table {
|
||||
hit: "first",
|
||||
inputs: &["applicant.code", "applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: &[
|
||||
("r1", &["'a\"b'", "< 18"], &["0.1"]),
|
||||
("r2", &["\"c\\d\"", "< 18"], &["0.1"]),
|
||||
("r3", &["\"e\"", "< 18"], &["0.1"]),
|
||||
("r4", &["\"e\"", ">= 18"], &["0.2"]),
|
||||
],
|
||||
};
|
||||
let original = table.content();
|
||||
let inputs: Vec<Value> = ["a\"b", "c\\d", "e", "f"]
|
||||
.iter()
|
||||
.flat_map(|code| {
|
||||
[10, 30].map(|age| {
|
||||
json!({ "applicant": { "tier": "gold", "code": code, "age": age, "scores": [], "vip": false } })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for diagnostics in [table.policy_diagnostics(), table.graph_diagnostics()] {
|
||||
let (before, rules) = compressed(diagnostics).expect("compressible");
|
||||
assert_eq!(before, 4);
|
||||
let codes = row_summary(&rules);
|
||||
assert!(
|
||||
codes[0].starts_with("i0='a\"b', \"c\\d\", \"e\" "),
|
||||
"{codes:?}"
|
||||
);
|
||||
let mut compact = original.clone();
|
||||
compact["rules"] = rules;
|
||||
let before = outputs_for(original.clone(), &inputs).await;
|
||||
let after = outputs_for(compact.clone(), &inputs).await;
|
||||
assert_eq!(before.0, after.0, "policy {compact}");
|
||||
assert_eq!(before.1, after.1, "graph {compact}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_exclusions_still_accept_lists_and_objects() {
|
||||
let table = Table {
|
||||
hit: "first",
|
||||
inputs: &["applicant.code"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: &[
|
||||
("r1", &["!= \"a\" and != \"b\" and != 1"], &["1"]),
|
||||
("r2", &["1"], &["1"]),
|
||||
("r3", &[""], &["2"]),
|
||||
],
|
||||
};
|
||||
let original = table.content();
|
||||
let (_, rules) = compressed(table.graph_diagnostics()).expect("compressible");
|
||||
assert_eq!(
|
||||
row_summary(&rules),
|
||||
vec!["i0=!= \"a\" and != \"b\" o0=1", "i0= o0=2"]
|
||||
);
|
||||
let mut compact = original.clone();
|
||||
compact["rules"] = rules;
|
||||
let decision = |content: &Value| {
|
||||
let mut graph = table.graph_json();
|
||||
for node in graph["nodes"].as_array_mut().expect("nodes") {
|
||||
if node["id"] == "dt" {
|
||||
node["content"] = content.clone();
|
||||
}
|
||||
}
|
||||
let DecisionContent::Graph(graph) = serde_json::from_value(graph).expect("graph") else {
|
||||
panic!("graph");
|
||||
};
|
||||
Decision::from(graph)
|
||||
};
|
||||
let (before, after) = (decision(&original), decision(&compact));
|
||||
for code in [
|
||||
json!([1]),
|
||||
json!({ "k": 1 }),
|
||||
Value::Null,
|
||||
json!(1),
|
||||
json!(2),
|
||||
json!("a"),
|
||||
json!("b"),
|
||||
json!("c"),
|
||||
json!(true),
|
||||
] {
|
||||
let input = json!({ "applicant": { "tier": "gold", "code": code, "age": 1, "scores": [], "vip": false } });
|
||||
let outcome = |result: Result<zen_engine::DecisionGraphResponse, _>| -> Value {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let output: Value = response.result.into();
|
||||
output
|
||||
.pointer("/applicant/discount")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
Err(_) => json!("error"),
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
outcome(after.evaluate(input.clone().into()).await),
|
||||
outcome(before.evaluate(input.clone().into()).await),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_compression_keeps_the_order_of_results() {
|
||||
let crossing = Table {
|
||||
hit: "collect",
|
||||
inputs: &["applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: &[
|
||||
("r1", &["< 18"], &["1"]),
|
||||
("r2", &["[20..40]"], &["2"]),
|
||||
("r3", &["[18..30]"], &["1"]),
|
||||
],
|
||||
};
|
||||
crossing.assert_compressed(None);
|
||||
|
||||
let apart = Table {
|
||||
hit: "collect",
|
||||
inputs: &["applicant.age"],
|
||||
outputs: &["applicant.discount"],
|
||||
rows: &[
|
||||
("r1", &["< 18"], &["1"]),
|
||||
("r2", &["> 50"], &["2"]),
|
||||
("r3", &["[18..30]"], &["1"]),
|
||||
],
|
||||
};
|
||||
apart.assert_compressed(Some((3, &["i0=<= 30 o0=1", "i0=> 50 o0=2"])));
|
||||
let original = apart.content();
|
||||
let (_, rules) = compressed(apart.policy_diagnostics()).expect("compressible");
|
||||
let mut compact = original.clone();
|
||||
compact["rules"] = rules;
|
||||
let inputs: Vec<Value> = [10, 18, 25, 30, 35, 60]
|
||||
.iter()
|
||||
.map(|age| json!({ "applicant": { "tier": "gold", "age": age, "scores": [], "vip": false } }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
outputs_for(original.clone(), &inputs).await.0,
|
||||
outputs_for(compact.clone(), &inputs).await.0
|
||||
);
|
||||
let graph = |content: &Value| {
|
||||
let mut graph = apart.graph_json();
|
||||
for node in graph["nodes"].as_array_mut().expect("nodes") {
|
||||
if node["id"] == "dt" {
|
||||
node["content"] = content.clone();
|
||||
}
|
||||
}
|
||||
let DecisionContent::Graph(graph) = serde_json::from_value(graph).expect("graph") else {
|
||||
panic!("graph");
|
||||
};
|
||||
Decision::from(graph)
|
||||
};
|
||||
let (before, after) = (graph(&original), graph(&compact));
|
||||
for input in inputs {
|
||||
let before: Value = before
|
||||
.evaluate(input.clone().into())
|
||||
.await
|
||||
.expect("graph")
|
||||
.result
|
||||
.into();
|
||||
let after: Value = after
|
||||
.evaluate(input.clone().into())
|
||||
.await
|
||||
.expect("graph")
|
||||
.result
|
||||
.into();
|
||||
assert_eq!(before, after, "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user