haematite_cli/serve/query/parser/
match.rs1use super::{Input, Refused, Source, Span, Spanned, Stage, bare, first, spans};
4
5#[derive(Debug, PartialEq, Eq)]
6pub struct Node {
7 pub binding: Spanned<String>,
8 pub label: Spanned<String>,
9 pub span: Span,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Direction {
14 Outgoing,
15 Incoming,
16}
17
18#[derive(Debug, PartialEq, Eq)]
19pub struct Relation {
20 pub kind: Spanned<String>,
21 pub direction: Direction,
22 pub span: Span,
23}
24
25pub(super) fn read(text: &str, start: usize) -> Result<Spanned<Stage>, Refused> {
27 let rest = text.trim_start();
28 let span = spans::stage(rest, start + text.len() - rest.len());
29 let text = rest.split_at(span.end - span.start).0;
30 let mut input = Input { text, rest: text };
31 if input.word() != "match" {
32 return Err(refuse(&input, span.start, "the `match` keyword"));
33 }
34 input.advance(5);
35 input.trim();
36 if !input.rest.starts_with('(') {
37 return Err(Refused::NotYet(
38 "a `match` stage without a labelled node",
39 Span {
40 start: span.start,
41 end: span.start + 5,
42 },
43 ));
44 }
45 let mut nodes = vec![node(&mut input, span.start)?];
46 input.trim();
47 let mut relations = Vec::new();
48 while !input.rest.is_empty() {
49 suffix(&input, span.start)?;
50 let relation = relation(&mut input, span.start)?;
51 if relations.len() == 7 {
52 return Err(Refused::NotYet(
53 "more than seven hops in `match`",
54 relation.span,
55 ));
56 }
57 relations.push(relation);
58 nodes.push(node(&mut input, span.start)?);
59 input.trim();
60 }
61 Ok(Spanned {
62 value: Stage::Match { nodes, relations },
63 span,
64 })
65}
66
67pub(super) fn position(
69 stage: &Stage,
70 source: &Source,
71 preceding: usize,
72 span: Span,
73) -> Result<(), Refused> {
74 let Stage::Match { nodes, .. } = stage else {
75 return Ok(());
76 };
77 if matches!(source, Source::Shaped(roots) if roots.len() > 1) {
78 return Err(Refused::NotYet(
79 "`match` after a shape with several roots",
80 span,
81 ));
82 }
83 if preceding != 0 {
84 return Err(Refused::NotYet(
85 "`match` anywhere but first or immediately after its collection",
86 span,
87 ));
88 }
89 let Some(node) = nodes.first() else {
90 return Err(Refused::NotYet("a `match` node with no label", span));
91 };
92 if let Source::Collection { name, .. } = source
93 && *name != node.label.value
94 {
95 return Err(Refused::NotYet(
96 "`match` after a collection other than its first node's label",
97 node.label.span,
98 ));
99 }
100 Ok(())
101}
102
103fn node(input: &mut Input<'_>, start: usize) -> Result<Node, Refused> {
104 input.trim();
105 let begin = start + input.position();
106 if !input.take("(") {
107 return Err(refuse(input, start, "a `match` node pattern"));
108 }
109 input.trim();
110 let binding = binding(input, start)?;
111 input.trim();
112 if input.rest.starts_with('{') {
113 return Err(refuse(input, start, "a `match` property map"));
114 }
115 if !input.take(":") {
116 return Err(refuse(input, start, "a `match` node with no label"));
117 }
118 input.trim();
119 let label = name(input, start, "a `match` node with no label")?;
120 input.trim();
121 if input.rest.starts_with('{') {
122 return Err(refuse(input, start, "a `match` property map"));
123 }
124 if input.rest.starts_with(':') {
125 return Err(refuse(input, start, "multiple labels on a `match` node"));
126 }
127 if !input.take(")") {
128 return Err(refuse(input, start, "an unclosed node in `match`"));
129 }
130 Ok(Node {
131 binding,
132 label,
133 span: Span {
134 start: begin,
135 end: start + input.position(),
136 },
137 })
138}
139
140fn binding(input: &mut Input<'_>, start: usize) -> Result<Spanned<String>, Refused> {
141 let length = input
142 .rest
143 .chars()
144 .take_while(|character| {
145 !character.is_whitespace() && !matches!(*character, ':' | ')' | '(' | '{' | '[' | ']')
146 })
147 .map(char::len_utf8)
148 .sum();
149 let value = input.rest.split_at(length).0;
150 let valid = value
151 .bytes()
152 .next()
153 .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_')
154 && value
155 .bytes()
156 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
157 if !valid {
158 let token = input.token_span();
159 return Err(Refused::NotYet(
160 "a `match` binding name outside [a-z_][a-z0-9_]*",
161 Span {
162 start: start + token.start,
163 end: start + token.start + length.max(token.end - token.start),
164 },
165 ));
166 }
167 let span = Span {
168 start: start + input.position(),
169 end: start + input.position() + length,
170 };
171 let value = value.to_owned();
172 input.advance(length);
173 Ok(Spanned { value, span })
174}
175
176fn name(
177 input: &mut Input<'_>,
178 start: usize,
179 missing: &'static str,
180) -> Result<Spanned<String>, Refused> {
181 let length = input
182 .rest
183 .chars()
184 .take_while(|character| bare(*character))
185 .map(char::len_utf8)
186 .sum();
187 let value = input.rest.split_at(length).0;
188 if !value.starts_with(first) {
189 return Err(refuse(input, start, missing));
190 }
191 let span = Span {
192 start: start + input.position(),
193 end: start + input.position() + length,
194 };
195 let value = value.to_owned();
196 input.advance(length);
197 Ok(Spanned { value, span })
198}
199
200fn relation(input: &mut Input<'_>, start: usize) -> Result<Relation, Refused> {
201 let begin = start + input.position();
202 let direction = if input.take("<-") {
203 Direction::Incoming
204 } else if input.take("-") {
205 Direction::Outgoing
206 } else {
207 return Err(refuse(input, start, "an unsupported `match` suffix"));
208 };
209 input.trim();
210 if !input.take("[") {
211 return Err(refuse(input, start, "a `match` relation with no kind"));
212 }
213 input.trim();
214 if !input.take(":") {
215 let construct = if input.rest.starts_with(']') {
216 "a `match` relation with no kind"
217 } else {
218 "a `match` relation binding"
219 };
220 return Err(refuse(input, start, construct));
221 }
222 input.trim();
223 let kind = name(input, start, "a `match` relation with no kind")?;
224 input.trim();
225 if input.rest.starts_with('*') {
226 return Err(refuse(input, start, "a variable-length `match` relation"));
227 }
228 if input.rest.starts_with('{') {
229 return Err(refuse(input, start, "a `match` property map"));
230 }
231 if !input.take("]") {
232 return Err(refuse(input, start, "an unclosed `match` relation kind"));
233 }
234 input.trim();
235 let arrow = match direction {
236 Direction::Outgoing => "->",
237 Direction::Incoming => "-",
238 };
239 if direction == Direction::Incoming && input.rest.starts_with("->") {
240 return Err(refuse(input, start, "a bidirectional `match` relation"));
241 }
242 if !input.take(arrow) {
243 return Err(refuse(
244 input,
245 start,
246 "an undirected or incomplete `match` arrow",
247 ));
248 }
249 Ok(Relation {
250 kind,
251 direction,
252 span: Span {
253 start: begin,
254 end: start + input.position(),
255 },
256 })
257}
258
259fn suffix(input: &Input<'_>, start: usize) -> Result<(), Refused> {
260 let construct = if input.word().eq_ignore_ascii_case("where") {
261 Some("a `match` WHERE clause")
262 } else if input.rest.starts_with('{') {
263 Some("a `match` property map")
264 } else if input.rest.starts_with(',') {
265 Some("multiple patterns in `match`")
266 } else if input.rest.starts_with('(') {
267 Some("two `match` nodes without a relation")
268 } else {
269 None
270 };
271 construct.map_or(Ok(()), |construct| Err(refuse(input, start, construct)))
272}
273
274fn refuse(input: &Input<'_>, start: usize, construct: &'static str) -> Refused {
275 let span = input.token_span();
276 Refused::NotYet(
277 construct,
278 Span {
279 start: start + span.start,
280 end: start + span.end,
281 },
282 )
283}
284
285#[cfg(test)]
286#[path = "match_tests.rs"]
287mod tests;
288
289#[cfg(test)]
290#[path = "match_contract_tests.rs"]
291mod contract_tests;
292
293#[cfg(test)]
294#[path = "match_library_tests.rs"]
295mod library_tests;
296
297#[cfg(test)]
298#[path = "match_chain_tests.rs"]
299mod chain_tests;