command: Operand carries a source span

Each WHERE-expression Operand now records the byte span of the
terminal it was built from — the precise per-literal highlight
target for an expression WARNING (finishing ADR-0027 §2's
highlight/hint wiring). parse_operand captures MatchedItem::span;
the RowFilter::eq convenience constructor uses Operand::NO_SPAN.

PartialEq is hand-written to ignore the span — it is editor
metadata, so Command equality stays whitespace- and
position-independent, which the Expr test corpus relies on.
No behaviour change; 1100 tests still pass, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 09:20:52 +00:00
parent 39b92a7558
commit 426e80185f
4 changed files with 94 additions and 27 deletions
+30 -15
View File
@@ -520,22 +520,28 @@ impl<'a> ExprParser<'a> {
}
/// `operand := literal | column_ref`.
///
/// Every operand records the byte `span` of the terminal it
/// was built from — the precise highlight target for an
/// expression WARNING (ADR-0027).
fn parse_operand(&mut self) -> Result<Operand, ValidationError> {
let item = self
.advance()
.ok_or_else(|| drift_error("expected an operand"))?;
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column(item.text.clone()))
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Word("null") => Ok(Operand::Literal(Value::Null)),
MatchedKind::Word("true") => Ok(Operand::Literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(Operand::Literal(Value::Bool(false))),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(Operand::Literal(Value::Number(item.text.clone())))
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(Operand::Literal(Value::Text(item.text.clone())))
Ok(literal(Value::Text(item.text.clone())))
}
_ => Err(drift_error("expected a column or literal operand")),
}
@@ -598,11 +604,24 @@ mod tests {
}
fn col(name: &str) -> Operand {
Operand::Column(name.to_string())
Operand::Column {
name: name.to_string(),
span: Operand::NO_SPAN,
}
}
/// A literal operand with no source span — the span is
/// ignored by `Operand`'s `PartialEq`, so test trees built
/// with this compare equal to walked ones.
fn lit(value: Value) -> Operand {
Operand::Literal {
value,
span: Operand::NO_SPAN,
}
}
fn num(n: &str) -> Operand {
Operand::Literal(Value::Number(n.to_string()))
lit(Value::Number(n.to_string()))
}
fn compare(left: Operand, op: CompareOp, right: Operand) -> Expr {
@@ -644,16 +663,12 @@ mod tests {
compare(
col("Name"),
CompareOp::Eq,
Operand::Literal(Value::Text("Ada".to_string())),
lit(Value::Text("Ada".to_string())),
),
);
assert_eq!(
parse_expr("Active = true"),
compare(
col("Active"),
CompareOp::Eq,
Operand::Literal(Value::Bool(true)),
),
compare(col("Active"), CompareOp::Eq, lit(Value::Bool(true))),
);
assert_eq!(
parse_expr("a = -7"),
@@ -708,7 +723,7 @@ mod tests {
parse_expr("Name like 'A%'"),
Expr::Predicate(Predicate::Like {
target: col("Name"),
pattern: Operand::Literal(Value::Text("A%".to_string())),
pattern: lit(Value::Text("A%".to_string())),
negated: false,
}),
);