LCOV - code coverage report
Current view: top level - antares-ql/src - render.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.9 % 146 143
Test Date: 2026-09-21 10:31:06 Functions: 82.6 % 23 19

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Render an AST back to 4.9 `q=` syntax, so a rewritten query can travel
       3              : //! on as a query string. `parse_q(&node.to_string())` yields `node` again,
       4              : //! with one limit the grammar itself imposes: the operand of `patternOp` /
       5              : //! `notPatternOp` is a `RegExp`, not a `quotedStr`, so it is written back
       6              : //! verbatim between quotes and a pattern whose own text carries a `\"`
       7              : //! cannot be spelled as a Query Term at all.
       8              : 
       9              : use crate::{CmpOp, Link, QNode, QPath, QValue};
      10              : use std::fmt;
      11              : 
      12              : impl fmt::Display for QNode {
      13          134 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      14          134 :         match self {
      15              :             // `;` binds tighter than `|`: an Or inside an And needs the
      16              :             // parentheses back, nothing else does.
      17           36 :             QNode::And(items) => join(f, items, ";", |n| matches!(n, QNode::Or(_))),
      18           26 :             QNode::Or(items) => join(f, items, "|", |n| matches!(n, QNode::Or(_))),
      19              :             // 4.9: a `RegExp` operand is not a `quotedStr`, so it is not
      20              :             // escaped as one — the backslashes in it are the pattern's.
      21           78 :             QNode::Cmp { path, op, value } => match (op, value) {
      22            8 :                 (CmpOp::Pattern | CmpOp::NotPattern, QValue::Str(s)) => {
      23            8 :                     write!(f, "{path}{op}\"{s}\"")
      24              :                 }
      25           70 :                 _ => write!(f, "{path}{op}{value}"),
      26              :             },
      27           26 :             QNode::Exists { path, negated } => {
      28           26 :                 if *negated {
      29            2 :                     f.write_str("!")?;
      30           24 :                 }
      31           26 :                 write!(f, "{path}")
      32              :             }
      33              :         }
      34          134 :     }
      35              : }
      36              : 
      37           30 : fn join(
      38           30 :     f: &mut fmt::Formatter<'_>,
      39           30 :     items: &[QNode],
      40           30 :     sep: &str,
      41           30 :     parens: impl Fn(&QNode) -> bool,
      42           30 : ) -> fmt::Result {
      43           62 :     for (i, n) in items.iter().enumerate() {
      44           62 :         if i > 0 {
      45           32 :             f.write_str(sep)?;
      46           30 :         }
      47           62 :         if parens(n) {
      48            6 :             write!(f, "({n})")?;
      49              :         } else {
      50           56 :             write!(f, "{n}")?;
      51              :         }
      52              :     }
      53           30 :     Ok(())
      54           30 : }
      55              : 
      56              : impl fmt::Display for QPath {
      57          104 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      58          104 :         for Link { attr, types } in &self.links {
      59            6 :             write!(f, "{attr}{{")?;
      60            6 :             if !types.is_empty() {
      61            2 :                 write!(f, "{}:", types.join(","))?;
      62            4 :             }
      63              :         }
      64          104 :         f.write_str(&self.path.join("."))?;
      65          104 :         if let Some(b) = &self.bracket {
      66            6 :             write!(f, "[{}]", b.join("."))?;
      67           98 :         }
      68          104 :         for _ in &self.links {
      69            6 :             f.write_str("}")?;
      70              :         }
      71          104 :         Ok(())
      72          104 :     }
      73              : }
      74              : 
      75              : impl fmt::Display for CmpOp {
      76           78 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      77           78 :         f.write_str(match self {
      78           36 :             CmpOp::Eq => "==",
      79            2 :             CmpOp::Ne => "!=",
      80           12 :             CmpOp::Gt => ">",
      81            2 :             CmpOp::Ge => ">=",
      82           18 :             CmpOp::Lt => "<",
      83            0 :             CmpOp::Le => "<=",
      84            4 :             CmpOp::Pattern => "~=",
      85            4 :             CmpOp::NotPattern => "!~=",
      86              :         })
      87           78 :     }
      88              : }
      89              : 
      90              : impl fmt::Display for QValue {
      91           78 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      92           78 :         match self {
      93              :             // 4.9 `quotedStr = String`: the RFC 8259 escaping the parser
      94              :             // decodes, put back. Always quoted — the parser reads an unquoted
      95              :             // date into Str too, and the quoted form parses back the same.
      96           34 :             QValue::Str(s) => write!(f, "{}", serde_json::Value::String(s.clone())),
      97           38 :             QValue::Num(n) => write!(f, "{n}"),
      98            2 :             QValue::Bool(b) => write!(f, "{b}"),
      99            2 :             QValue::List(items) => {
     100            4 :                 for (i, v) in items.iter().enumerate() {
     101            4 :                     if i > 0 {
     102            2 :                         f.write_str(",")?;
     103            2 :                     }
     104            4 :                     write!(f, "{v}")?;
     105              :                 }
     106            2 :                 Ok(())
     107              :             }
     108            2 :             QValue::Range(lo, hi) => write!(f, "{lo}..{hi}"),
     109              :         }
     110           78 :     }
     111              : }
     112              : 
     113              : #[cfg(test)]
     114              : mod tests {
     115              :     use crate::{parse_q, QNode, QPath, QValue};
     116              : 
     117              :     /// Every grammar shape survives parse → render → parse unchanged.
     118              :     #[test]
     119            2 :     fn render_round_trips_through_the_parser() {
     120           32 :         for q in [
     121            2 :             r#"brandName=="Mercedes""#,
     122            2 :             "speed>=5;heading<90",
     123            2 :             r#"speed>25|name~="^m""#,
     124            2 :             r#"(speed>25|heading>100);route=="550","551""#,
     125            2 :             "speed==10..40",
     126            2 :             "!heading;speed>1",
     127            2 :             "ref{Vehicle,Car:speed}>3",
     128            2 :             "a{b{c}}==1",
     129            2 :             r#"label[en]=="x""#,
     130            2 :             r#"address[city]=="Paris""#,
     131            2 :             "label[*]!=\"y\"",
     132            2 :             "x!~=\"^y\"",
     133            2 :             "t==2020-01-01T00:00:00Z",
     134            2 :             "flag==true",
     135            2 :             "a;b|c;d",
     136            2 :             "a|(b;c)|d",
     137            2 :         ] {
     138           32 :             let node = parse_q(q).expect(q);
     139           32 :             let rendered = node.to_string();
     140           32 :             let again = parse_q(&rendered).unwrap_or_else(|e| panic!("{q} → {rendered}: {e}"));
     141           32 :             assert_eq!(again, node, "{q} → {rendered}");
     142              :         }
     143            2 :     }
     144              : 
     145              :     /// A tree built by hand (an Or nested in an And, which the parser only
     146              :     /// produces through parentheses) renders with the parentheses restored.
     147              :     #[test]
     148            2 :     fn nested_or_inside_and_gets_its_parentheses_back() {
     149            2 :         let node = QNode::And(vec![
     150            2 :             QNode::Or(vec![
     151            2 :                 QNode::Exists {
     152            2 :                     path: QPath::dotted(vec!["a".into()]),
     153            2 :                     negated: false,
     154            2 :                 },
     155            2 :                 QNode::Exists {
     156            2 :                     path: QPath::dotted(vec!["b".into()]),
     157            2 :                     negated: false,
     158            2 :                 },
     159            2 :             ]),
     160            2 :             QNode::Cmp {
     161            2 :                 path: QPath::dotted(vec!["owner".into()]),
     162            2 :                 op: crate::CmpOp::Eq,
     163            2 :                 value: QValue::Str("t1".into()),
     164            2 :             },
     165            2 :         ]);
     166            2 :         assert_eq!(node.to_string(), r#"(a|b);owner=="t1""#);
     167            2 :         assert_eq!(parse_q(&node.to_string()).expect("parses"), node);
     168            2 :     }
     169              : 
     170              :     /// 4.9 `quotedStr = String`: a value carrying the RFC 8259 escapes is
     171              :     /// written back escaped, so the rendered Query Term parses to the value
     172              :     /// it was rendered from rather than to a truncated one.
     173              :     #[test]
     174            2 :     fn an_escaped_string_survives_the_round_trip() {
     175           16 :         for q in [
     176            2 :             r#"a=="say \"hi\"""#,
     177            2 :             r#"a=="back\\slash""#,
     178            2 :             r#"a=="line\nbreak""#,
     179            2 :             r#"a=="semi;colon""#,
     180            2 :             r#"a=="pipe|bar""#,
     181            2 :             r#"a=="comma,list""#,
     182            2 :             r#"a=="paren)close""#,
     183            2 :             r#"a=="dots..range""#,
     184            2 :         ] {
     185           16 :             let node = parse_q(q).expect(q);
     186           16 :             let rendered = node.to_string();
     187           16 :             let again = parse_q(&rendered).unwrap_or_else(|e| panic!("{q} → {rendered}: {e}"));
     188           16 :             assert_eq!(again, node, "{q} → {rendered}");
     189              :         }
     190            2 :     }
     191              : 
     192              :     /// The operand of `patternOp`/`notPatternOp` is a `RegExp`, not a
     193              :     /// `quotedStr`: its backslashes belong to the pattern and are neither
     194              :     /// decoded on the way in nor escaped on the way out.
     195              :     #[test]
     196            2 :     fn a_regexp_operand_keeps_its_own_backslashes() {
     197            4 :         for q in [r#"a~="^\d+$""#, r#"a!~="^[a-z]\.[0-9]{2}$""#] {
     198            4 :             let node = parse_q(q).expect(q);
     199            4 :             let QNode::Cmp { value, .. } = &node else {
     200            0 :                 panic!("{q}: expected a comparison")
     201              :             };
     202            4 :             let QValue::Str(pattern) = value else {
     203            0 :                 panic!("{q}: expected a string operand")
     204              :             };
     205            4 :             assert!(
     206            4 :                 pattern.contains('\\'),
     207              :                 "{q}: the regex kept its backslash: {pattern:?}"
     208              :             );
     209            4 :             assert_eq!(parse_q(&node.to_string()).expect("re-parses"), node, "{q}");
     210              :         }
     211            2 :     }
     212              : 
     213              :     #[test]
     214            2 :     fn the_ast_serializes() {
     215            2 :         let node = parse_q("speed>3").expect("parse");
     216            2 :         let json = serde_json::to_value(&node).expect("serialize");
     217            2 :         assert_eq!(json["Cmp"]["op"], "Gt", "{json}");
     218            2 :         assert_eq!(json["Cmp"]["path"]["path"][0], "speed", "{json}");
     219            2 :     }
     220              : }
        

Generated by: LCOV version 2.0-1