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 : }
|