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
+49 -5
View File
@@ -257,9 +257,15 @@ impl RowFilter {
#[must_use]
pub fn eq(column: impl Into<String>, value: Value) -> Self {
Self::Where(Expr::Predicate(Predicate::Compare {
left: Operand::Column(column.into()),
left: Operand::Column {
name: column.into(),
span: Operand::NO_SPAN,
},
op: CompareOp::Eq,
right: Operand::Literal(value),
right: Operand::Literal {
value,
span: Operand::NO_SPAN,
},
}))
}
}
@@ -322,10 +328,48 @@ pub enum Predicate {
/// A comparison operand — a column reference or a literal
/// (ADR-0026 §1: operands are never nested expressions).
#[derive(Debug, Clone, PartialEq, Eq)]
///
/// Each operand carries the byte `span` it occupied in the
/// source. The span feeds the precise per-literal WARNING
/// highlight (ADR-0027) and is otherwise editor metadata —
/// `PartialEq` is hand-written to **ignore** it, so two
/// operands are equal when their column / value match
/// regardless of where they were typed. This keeps `Command`
/// equality whitespace- and position-independent (the bulk of
/// the `Expr` test corpus relies on it).
#[derive(Debug, Clone, Eq)]
pub enum Operand {
Column(String),
Literal(Value),
Column { name: String, span: (usize, usize) },
Literal { value: Value, span: (usize, usize) },
}
impl Operand {
/// Span used for operands built without a source position
/// (the [`RowFilter::eq`] convenience constructor).
pub const NO_SPAN: (usize, usize) = (0, 0);
/// The byte range this operand occupied in the source —
/// [`Operand::NO_SPAN`] for programmatically-built operands.
#[must_use]
pub const fn span(&self) -> (usize, usize) {
match self {
Self::Column { span, .. } | Self::Literal { span, .. } => *span,
}
}
}
impl PartialEq for Operand {
/// Compares column name / literal value only — the source
/// `span` is deliberately excluded (see the type docs).
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
_ => false,
}
}
}
/// The six comparison operators. `<>` and `!=` both parse to
+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,
}),
);
+11 -3
View File
@@ -542,7 +542,13 @@ fn predicate_warnings(
}
const fn is_null_literal(operand: &Operand) -> bool {
matches!(operand, Operand::Literal(crate::dsl::value::Value::Null))
matches!(
operand,
Operand::Literal {
value: crate::dsl::value::Value::Null,
..
}
)
}
/// If one operand is a known column and the other a non-null
@@ -556,8 +562,10 @@ fn pair_type_mismatch(
columns: &[crate::completion::TableColumn],
) -> Option<String> {
let (column, literal) = match (a, b) {
(Operand::Column(c), Operand::Literal(v))
| (Operand::Literal(v), Operand::Column(c)) => (c, v),
(Operand::Column { name: c, .. }, Operand::Literal { value: v, .. })
| (Operand::Literal { value: v, .. }, Operand::Column { name: c, .. }) => {
(c, v)
}
_ => return None,
};
// `null` fits any column; `= NULL` is flagged separately.