Files
rdbms-playground/src/dsl/walker/driver.rs
T
claude@clouddev1 911a537a83 Walker: node-attached HintMode via Node::Hinted (ADR-0024 §HintMode-per-node)
Replaces the hint resolver's signature-matching (does the expected set
contain all five literal forms? an Ident{NewName}?) with a grammar-
declared annotation. New Node::Hinted { mode, inner } wrapper; the
walker records the mode in WalkContext::pending_hint_mode on entry and
clears it on any successful match (cursor moved past the slot — this
also undoes the leak where a failed Hinted branch of a Choice would
otherwise strand a stale mode). The resolver reads pending_hint_mode
directly.

Value-literal fallback slots carry ProseOnly; NewName ident slots carry
ForceProse. hint_mode_at_input_inner now delegates to
hint_resolution_at_input — one resolution path, no duplicated logic.
No behaviour change; the typing-surface matrix guards it.
2026-05-15 21:58:22 +00:00

822 lines
28 KiB
Rust

//! Per-node-kind walk dispatch (ADR-0024 §architecture).
//!
//! `walk_node` is the recursive workhorse that the public
//! `walk()` entry calls into for a `CommandNode`'s `shape`. It
//! tries to match `node` starting at `position`, mutating
//! `path` (matched terminals collected in declaration order) and
//! `per_byte` (highlight class assignments) as it goes.
//!
//! The return value distinguishes four cases:
//!
//! - `Matched { end }` — full match, walker consumed up to `end`.
//! - `NoMatch { … }` — node didn't engage at this position. For
//! `Optional` and `Choice` callers this is benign (try the
//! next branch / skip the optional); for `Seq` it's only
//! benign on the first child.
//! - `Incomplete { … }` — node committed (consumed at least one
//! terminal) but ran out of input. Surfaces as
//! `WalkOutcome::Incomplete` at the top level.
//! - `Failed { … }` — node committed and a content validator
//! rejected the value, or a hard structural failure occurred
//! mid-shape. Surfaces as `WalkOutcome::Mismatch` or
//! `WalkOutcome::ValidationFailed` at the top level.
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
};
#[derive(Debug, Clone)]
pub enum NodeWalkResult {
Matched {
end: usize,
/// Expectations contributed by Optional children that
/// skipped (matched zero terminals). Walker callers
/// merge these into the next failure's expected set so
/// completion sees the full "what could have appeared
/// here" union, not just the strictly-required next
/// terminal.
skipped: Vec<Expectation>,
},
/// Did not engage at this position. Caller decides whether
/// this is benign (Optional, Choice fallthrough) or a hard
/// failure (Seq mid-shape).
NoMatch {
position: usize,
expected: Vec<Expectation>,
},
/// Committed and ran out of input.
Incomplete {
position: usize,
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
}
const fn matched(end: usize) -> NodeWalkResult {
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[derive(Debug, Clone)]
pub enum FailureKind {
Mismatch { expected: Vec<Expectation> },
Validation(ValidationError),
}
pub fn walk_node(
source: &str,
position: usize,
node: &Node,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let pos = skip_whitespace(source, position);
let result = walk_node_inner(source, pos, node, ctx, path, per_byte);
// ADR-0024 §HintMode-per-node: `pending_hint_mode` records
// the Hinted slot the cursor is currently inside. Any
// successful match means the cursor advanced past whatever
// slot was pending — clear it. This also undoes the leak
// where a failed `Hinted` branch of a `Choice` sets the
// mode and the `Choice` then matches via a different
// branch: that branch's match clears the stale mode.
if matches!(result, NodeWalkResult::Matched { .. }) {
ctx.pending_hint_mode = None;
}
result
}
fn walk_node_inner(
source: &str,
pos: usize,
node: &Node,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
match node {
Node::Word(word) => walk_word(source, pos, word, path, per_byte),
Node::Punct(ch) => walk_punct(source, pos, *ch, path, per_byte),
Node::Ident {
source: src,
role,
validator,
highlight_override: _,
writes_table,
writes_column,
writes_user_listed_column,
} => walk_ident(
source,
pos,
*src,
role,
*validator,
*writes_table,
*writes_column,
*writes_user_listed_column,
ctx,
path,
per_byte,
),
Node::NumberLit { validator } => walk_number_lit(source, pos, *validator, path, per_byte),
Node::Literal(literal) => walk_literal(source, pos, literal, path, per_byte),
Node::StringLit => walk_string_lit(source, pos, path, per_byte),
Node::BlobLit => {
// BlobLit terminals are declared but no current grammar
// node uses them. Reaching this branch means a future
// grammar declared a BlobLit without walker support
// landing — surface as a hard failure so tests catch
// it loudly rather than silently mis-parsing.
NodeWalkResult::Failed {
position: pos,
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::DynamicSubgrammar(factory) => {
// ADR-0024 §sub-grammars: resolve the inner Node at
// walk time using the active `WalkContext`, then
// recursively walk it. `Box::leak` per-walk gives the
// inner static-slice fields (Choice/Seq) the lifetime
// they require; the leak is bounded by command-shape
// complexity per walk.
let resolved: &'static Node = Box::leak(Box::new(factory(ctx)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::TypedValueSlot {
ty,
column_name,
inner,
} => {
// ADR-0024 §Phase D §typed-value-slots. Tag the
// pending column type so the hint resolver can emit
// per-type prose at empty prefix. If a column name
// is embedded (insert column_value_list path), tag
// that too so the hint can mention the column by
// name. Clear on successful inner match — positions
// BETWEEN typed slots (post-comma, between values)
// don't carry stale hint state.
ctx.pending_value_type = Some(*ty);
if let Some(name) = column_name {
ctx.pending_value_column = Some((*name).to_string());
}
let result = walk_node(source, pos, inner, ctx, path, per_byte);
if matches!(result, NodeWalkResult::Matched { .. }) {
ctx.pending_value_type = None;
ctx.pending_value_column = None;
}
result
}
Node::Hinted { mode, inner } => {
// ADR-0024 §HintMode-per-node. Record the grammar's
// declared hint mode so the hint resolver can read
// it directly. The `walk_node` wrapper clears it on
// any successful match (the cursor moved past the
// slot), so a Hinted slot whose inner fails at EOF
// leaves the mode set for the resolver to read.
ctx.pending_hint_mode = Some(*mode);
walk_node(source, pos, inner, ctx, path, per_byte)
}
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
Node::Repeated {
inner,
separator,
min,
} => walk_repeated(source, pos, inner, *separator, *min, ctx, path, per_byte),
Node::BarePath => walk_bare_path(source, pos, path, per_byte),
Node::Choice(children) => walk_choice(source, pos, children, ctx, path, per_byte),
Node::Seq(children) => walk_seq(source, pos, children, ctx, path, per_byte),
Node::Optional(child) => walk_optional(source, pos, child, ctx, path, per_byte),
}
}
fn walk_word(
source: &str,
position: usize,
word: &crate::dsl::grammar::Word,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
// First scan an identifier-shape token at `position`; if
// none, we definitely don't have this keyword. If one, check
// it against the word's primary + aliases.
let Some((start, end)) = consume_ident(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Word(word.primary)],
};
};
let candidate = &source[start..end];
if word.matches(candidate) {
path.push(MatchedItem {
kind: MatchedKind::Word(word.primary),
text: candidate.to_string(),
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Keyword,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
} else {
NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Word(word.primary)],
}
}
}
fn walk_punct(
source: &str,
position: usize,
ch: char,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let bytes = source.as_bytes();
if position < bytes.len() && bytes[position] == ch as u8 {
path.push(MatchedItem {
kind: MatchedKind::Punct(ch),
text: ch.to_string(),
span: (position, position + 1),
});
per_byte.push(ByteClass {
start: position,
end: position + 1,
class: HighlightClass::Punct,
});
matched(position + 1)
} else {
NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Punct(ch)],
}
}
}
#[allow(clippy::too_many_arguments)]
fn walk_ident(
source: &str,
position: usize,
src: crate::dsl::grammar::IdentSource,
role: &'static str,
validator: Option<crate::dsl::grammar::IdentValidator>,
writes_table: bool,
writes_column: bool,
writes_user_listed_column: bool,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_ident(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Ident { role, source: src }],
};
};
let text = source[start..end].to_string();
if let Some(v) = validator
&& let Err(err) = v(&text)
{
return NodeWalkResult::Failed {
position: start,
kind: FailureKind::Validation(err),
};
}
// ADR-0024 §Phase D: schema-aware writes. When the ident is
// a Tables source with `writes_table`, resolve the matched
// name against the schema cache and populate current_table /
// current_table_columns so subsequent dynamic sub-grammars
// can read them. `writes_column` resolves against the
// already-populated `current_table_columns`.
if writes_table && matches!(src, crate::dsl::grammar::IdentSource::Tables) {
ctx.current_table = Some(text.clone());
ctx.current_table_columns = ctx
.schema
.and_then(|s| s.columns_for_table(&text).map(<[_]>::to_vec));
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
ctx.current_column = ctx.current_table_columns.as_ref().and_then(|cols| {
cols.iter()
.find(|c| c.name.eq_ignore_ascii_case(&text))
.cloned()
});
// Surface the column name to the hint resolver too —
// this is the `update <T> set <col>=` / `where <col>=`
// path. The matching column's canonical name (from the
// schema) wins over the user's spelling so the hint
// mirrors what's in the schema.
ctx.pending_value_column = ctx
.current_column
.as_ref()
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
// explicit selection. Schema-canonical name wins over
// user's spelling so downstream lookups (typed slot
// dispatch, hint rendering) are consistent.
let canonical = ctx
.current_table_columns
.as_ref()
.and_then(|cols| {
cols.iter()
.find(|c| c.name.eq_ignore_ascii_case(&text))
.map(|c| c.name.clone())
})
.unwrap_or_else(|| text.clone());
ctx.user_listed_columns
.get_or_insert_with(Vec::new)
.push(canonical);
}
path.push(MatchedItem {
kind: MatchedKind::Ident { role },
text,
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Identifier,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_string_lit(
source: &str,
position: usize,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some(((start, end), content)) = consume_string_literal(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::StringLit],
};
};
path.push(MatchedItem {
kind: MatchedKind::StringLit,
text: content,
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_literal(
source: &str,
position: usize,
literal: &'static str,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let bytes = source.as_bytes();
let lit_bytes = literal.as_bytes();
if position + lit_bytes.len() > bytes.len() {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
if &bytes[position..position + lit_bytes.len()] != lit_bytes {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
// Lookahead: if the literal is a single digit / alphabetic
// run, the next byte must not extend it (so `1` doesn't
// half-match `12`).
let end = position + lit_bytes.len();
let last = lit_bytes[lit_bytes.len() - 1];
let last_is_word = last.is_ascii_alphanumeric() || last == b'_';
if last_is_word && end < bytes.len() {
let next = bytes[end];
if next.is_ascii_alphanumeric() || next == b'_' {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
}
// Highlight class follows the literal's shape: digits get
// Number; letters get Keyword; mixed defaults to Keyword.
let class = if lit_bytes.iter().all(|b| b.is_ascii_digit()) {
HighlightClass::Number
} else {
HighlightClass::Keyword
};
path.push(MatchedItem {
kind: MatchedKind::Word(literal),
text: literal.to_string(),
span: (position, end),
});
per_byte.push(ByteClass {
start: position,
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_number_lit(
source: &str,
position: usize,
validator: Option<crate::dsl::grammar::NumberValidator>,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_number_literal(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::NumberLit],
};
};
let text = source[start..end].to_string();
if let Some(v) = validator
&& let Err(err) = v(&text)
{
return NodeWalkResult::Failed {
position: start,
kind: FailureKind::Validation(err),
};
}
path.push(MatchedItem {
kind: MatchedKind::NumberLit,
text,
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_flag(
source: &str,
position: usize,
name: &'static str,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_flag(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Flag(name)],
};
};
// `consume_flag` guarantees `start..end` covers `--<body>`.
let body = &source[start + 2..end];
if body != name {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Flag(name)],
};
}
path.push(MatchedItem {
kind: MatchedKind::Flag(name),
text: source[start..end].to_string(),
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
#[allow(clippy::too_many_arguments)]
fn walk_repeated(
source: &str,
position: usize,
inner: &Node,
separator: Option<&Node>,
min: usize,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let mut cur = position;
let mut count = 0_usize;
let mut last_expected: Option<Vec<Expectation>> = None;
loop {
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
// Track whether the separator successfully consumed
// before the inner attempt. Used below to distinguish
// "user typed `,` then stopped at EOF — mid-typing the
// next item" from "list naturally ended at the inner
// boundary".
let mut sep_consumed_to: Option<usize> = None;
let result = if count == 0 {
walk_node(source, cur, inner, ctx, path, per_byte)
} else if let Some(sep) = separator {
let sep_saved_path = path.items.len();
let sep_saved_byte = per_byte.len();
match walk_node(source, cur, sep, ctx, path, per_byte) {
NodeWalkResult::Matched { end, .. } => {
sep_consumed_to = Some(end);
walk_node(source, end, inner, ctx, path, per_byte)
}
NodeWalkResult::NoMatch { .. } => {
path.items.truncate(sep_saved_path);
per_byte.truncate(sep_saved_byte);
break;
}
other => return other,
}
} else {
walk_node(source, cur, inner, ctx, path, per_byte)
};
match result {
NodeWalkResult::Matched { end, .. } => {
cur = end;
count += 1;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
// next item — propagate as Incomplete so the
// outer walker classifies the input as
// mid-typing rather than rolling the separator
// back and producing a structural Mismatch at
// the separator position.
//
// Without this branch, `insert into T (a, ` at
// EOF would roll back the `,`, then the outer
// `(`-list expected `)` at `cur`, see the
// separator instead, and report a definite
// error at the separator. Real users hit this
// every time they type a comma and pause.
if let Some(post_sep) = sep_consumed_to {
let post_ws = skip_whitespace(source, post_sep);
if post_ws >= source.len() {
return NodeWalkResult::Incomplete {
position: inner_pos,
expected,
};
}
}
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
last_expected = Some(expected);
break;
}
other => return other,
}
}
if count < min {
return NodeWalkResult::NoMatch {
position: cur,
expected: last_expected.unwrap_or_default(),
};
}
// The "could continue with another inner" expectations
// become this Repeated's `skipped` set so the caller's
// expected-set surfaces them at completion time.
NodeWalkResult::Matched {
end: cur,
skipped: last_expected.unwrap_or_default(),
}
}
fn walk_bare_path(
source: &str,
position: usize,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_bare_path(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::BarePath],
};
};
let text = source[start..end].to_string();
path.push(MatchedItem {
kind: MatchedKind::BarePath,
text,
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_choice(
source: &str,
position: usize,
children: &[Node],
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let mut all_expected: Vec<Expectation> = Vec::new();
for child in children {
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
match walk_node(source, position, child, ctx, path, per_byte) {
m @ NodeWalkResult::Matched { .. } => return m,
NodeWalkResult::NoMatch { expected, .. } => {
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
merge_expected(&mut all_expected, expected);
}
other => return other,
}
}
NodeWalkResult::NoMatch {
position,
expected: all_expected,
}
}
fn walk_seq(
source: &str,
position: usize,
children: &[Node],
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let mut cur = position;
let mut idx = 0;
// Carries expectations from skipped-Optional children so
// that a NoMatch on a later child reports the union of "you
// could have typed any of these" — making the completion
// engine see optional connectives that haven't been typed.
let mut pending_skipped: Vec<Expectation> = Vec::new();
for child in children {
match walk_node(source, cur, child, ctx, path, per_byte) {
NodeWalkResult::Matched { end, skipped } => {
if end == cur {
// Child matched zero terminals (Optional skipped,
// empty Repeated, empty Seq). Accumulate its
// would-be expectations into pending.
for e in skipped {
if !pending_skipped.contains(&e) {
pending_skipped.push(e);
}
}
} else {
// Child consumed terminals — the "missing optional"
// window closed; reset the pending list.
pending_skipped.clear();
pending_skipped.extend(skipped);
}
cur = end;
idx += 1;
}
NodeWalkResult::NoMatch {
position,
mut expected,
} => {
// Merge pending skipped-optional expectations with this
// child's expected set.
for e in std::mem::take(&mut pending_skipped) {
if !expected.contains(&e) {
expected.push(e);
}
}
if idx == 0 {
return NodeWalkResult::NoMatch { position, expected };
}
let post_ws = skip_whitespace(source, position);
if post_ws >= source.len() {
return NodeWalkResult::Incomplete {
position: post_ws,
expected,
};
}
return NodeWalkResult::Failed {
position: post_ws,
kind: FailureKind::Mismatch { expected },
};
}
NodeWalkResult::Incomplete {
position,
mut expected,
} => {
for e in std::mem::take(&mut pending_skipped) {
if !expected.contains(&e) {
expected.push(e);
}
}
return NodeWalkResult::Incomplete { position, expected };
}
NodeWalkResult::Failed { position, kind } => {
return NodeWalkResult::Failed { position, kind };
}
}
}
NodeWalkResult::Matched {
end: cur,
skipped: pending_skipped,
}
}
fn walk_optional(
source: &str,
position: usize,
child: &Node,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
let result = walk_node(source, position, child, ctx, path, per_byte);
let inner_committed = path.items.len() > saved_path_len;
match result {
m @ NodeWalkResult::Matched { .. } => m,
NodeWalkResult::NoMatch { expected, .. } => {
// Inner didn't engage at all — skip the Optional
// but carry the inner's expectations so the caller's
// expected-set sees them.
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched {
end: position,
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
let _ = p;
NodeWalkResult::Matched {
end: position,
skipped: expected,
}
}
NodeWalkResult::Failed {
kind: FailureKind::Mismatch { expected },
..
} if !inner_committed => {
// Inner reported Mismatch without consuming
// anything — roll back and skip.
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched {
end: position,
skipped: expected,
}
}
// Inner committed (consumed at least one terminal) but
// then ran out / hit a mismatch. Propagate the failure
// up — the user is mid-typing the optional's content and
// we'd lose their intent by rolling back. (Pre-fix
// behavior matched chumsky's `or_not` rollback, but
// that conflates "Form A in progress" with "Form C with
// trailing junk" — see e.g. `insert into T (a, b, c)
// values (1, 2, 3` losing the `values (…)` partial.)
// Validation failures already propagate as a separate
// branch below.
propagated @ (NodeWalkResult::Incomplete { .. } | NodeWalkResult::Failed { .. }) => {
propagated
}
}
}
fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
for e in src {
if !dst.contains(&e) {
dst.push(e);
}
}
}