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
+4 -4
View File
@@ -4251,8 +4251,8 @@ fn compile_operand(
params: &mut Vec<rusqlite::types::Value>, params: &mut Vec<rusqlite::types::Value>,
) -> String { ) -> String {
match operand { match operand {
Operand::Column(name) => quote_ident(name), Operand::Column { name, .. } => quote_ident(name),
Operand::Literal(value) => { Operand::Literal { value, .. } => {
params.push(bind_where_literal(value, against)); params.push(bind_where_literal(value, against));
format!("?{}", params.len()) format!("?{}", params.len())
} }
@@ -4264,12 +4264,12 @@ fn compile_operand(
/// yield `None`. /// yield `None`.
fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option<Type> { fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option<Type> {
match operand { match operand {
Operand::Column(name) => schema Operand::Column { name, .. } => schema
.columns .columns
.iter() .iter()
.find(|c| c.name.eq_ignore_ascii_case(name)) .find(|c| c.name.eq_ignore_ascii_case(name))
.and_then(|c| c.user_type), .and_then(|c| c.user_type),
Operand::Literal(_) => None, Operand::Literal { .. } => None,
} }
} }
+49 -5
View File
@@ -257,9 +257,15 @@ impl RowFilter {
#[must_use] #[must_use]
pub fn eq(column: impl Into<String>, value: Value) -> Self { pub fn eq(column: impl Into<String>, value: Value) -> Self {
Self::Where(Expr::Predicate(Predicate::Compare { Self::Where(Expr::Predicate(Predicate::Compare {
left: Operand::Column(column.into()), left: Operand::Column {
name: column.into(),
span: Operand::NO_SPAN,
},
op: CompareOp::Eq, 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 /// A comparison operand — a column reference or a literal
/// (ADR-0026 §1: operands are never nested expressions). /// (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 { pub enum Operand {
Column(String), Column { name: String, span: (usize, usize) },
Literal(Value), 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 /// The six comparison operators. `<>` and `!=` both parse to
+30 -15
View File
@@ -520,22 +520,28 @@ impl<'a> ExprParser<'a> {
} }
/// `operand := literal | column_ref`. /// `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> { fn parse_operand(&mut self) -> Result<Operand, ValidationError> {
let item = self let item = self
.advance() .advance()
.ok_or_else(|| drift_error("expected an operand"))?; .ok_or_else(|| drift_error("expected an operand"))?;
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind { match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => { 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("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(Operand::Literal(Value::Bool(true))), MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(Operand::Literal(Value::Bool(false))), MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => { MatchedKind::NumberLit => {
Ok(Operand::Literal(Value::Number(item.text.clone()))) Ok(literal(Value::Number(item.text.clone())))
} }
MatchedKind::StringLit => { 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")), _ => Err(drift_error("expected a column or literal operand")),
} }
@@ -598,11 +604,24 @@ mod tests {
} }
fn col(name: &str) -> Operand { 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 { 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 { fn compare(left: Operand, op: CompareOp, right: Operand) -> Expr {
@@ -644,16 +663,12 @@ mod tests {
compare( compare(
col("Name"), col("Name"),
CompareOp::Eq, CompareOp::Eq,
Operand::Literal(Value::Text("Ada".to_string())), lit(Value::Text("Ada".to_string())),
), ),
); );
assert_eq!( assert_eq!(
parse_expr("Active = true"), parse_expr("Active = true"),
compare( compare(col("Active"), CompareOp::Eq, lit(Value::Bool(true))),
col("Active"),
CompareOp::Eq,
Operand::Literal(Value::Bool(true)),
),
); );
assert_eq!( assert_eq!(
parse_expr("a = -7"), parse_expr("a = -7"),
@@ -708,7 +723,7 @@ mod tests {
parse_expr("Name like 'A%'"), parse_expr("Name like 'A%'"),
Expr::Predicate(Predicate::Like { Expr::Predicate(Predicate::Like {
target: col("Name"), target: col("Name"),
pattern: Operand::Literal(Value::Text("A%".to_string())), pattern: lit(Value::Text("A%".to_string())),
negated: false, negated: false,
}), }),
); );
+11 -3
View File
@@ -542,7 +542,13 @@ fn predicate_warnings(
} }
const fn is_null_literal(operand: &Operand) -> bool { 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 /// 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], columns: &[crate::completion::TableColumn],
) -> Option<String> { ) -> Option<String> {
let (column, literal) = match (a, b) { let (column, literal) = match (a, b) {
(Operand::Column(c), Operand::Literal(v)) (Operand::Column { name: c, .. }, Operand::Literal { value: v, .. })
| (Operand::Literal(v), Operand::Column(c)) => (c, v), | (Operand::Literal { value: v, .. }, Operand::Column { name: c, .. }) => {
(c, v)
}
_ => return None, _ => return None,
}; };
// `null` fits any column; `= NULL` is flagged separately. // `null` fits any column; `= NULL` is flagged separately.