LCOV - code coverage report
Current view: top level - antares-ql/src - render.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 145 145
Test Date: 2026-11-02 13:21:27 Functions: 91.3 % 23 21

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

Generated by: LCOV version 2.0-1