diff --git a/src/db.rs b/src/db.rs index 7dad36c..2fd8d61 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4251,8 +4251,8 @@ fn compile_operand( params: &mut Vec, ) -> String { match operand { - Operand::Column(name) => quote_ident(name), - Operand::Literal(value) => { + Operand::Column { name, .. } => quote_ident(name), + Operand::Literal { value, .. } => { params.push(bind_where_literal(value, against)); format!("?{}", params.len()) } @@ -4264,12 +4264,12 @@ fn compile_operand( /// yield `None`. fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option { match operand { - Operand::Column(name) => schema + Operand::Column { name, .. } => schema .columns .iter() .find(|c| c.name.eq_ignore_ascii_case(name)) .and_then(|c| c.user_type), - Operand::Literal(_) => None, + Operand::Literal { .. } => None, } } diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 2e1ccc8..3420cd7 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -257,9 +257,15 @@ impl RowFilter { #[must_use] pub fn eq(column: impl Into, 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 diff --git a/src/dsl/grammar/expr.rs b/src/dsl/grammar/expr.rs index c6a70d5..fdc81b7 100644 --- a/src/dsl/grammar/expr.rs +++ b/src/dsl/grammar/expr.rs @@ -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 { 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, }), ); diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 3d71bb7..56a84f5 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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 { 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.