Monorepo for Tangled
tangled.org
1//! subset of microcosm.blue/RecordPath
2
3use serde_json::Value;
4
5#[derive(Debug, Clone, PartialEq)]
6pub(crate) enum Modifier {
7 /// `[]`
8 Elements,
9 /// `[nsid]`
10 UnionElements(String),
11 /// `{nsid}`
12 Union(String),
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub(crate) struct Segment {
17 field: String,
18 modifier: Option<Modifier>,
19}
20
21pub(crate) fn parse_record_path(path: &str) -> Result<Vec<Segment>, String> {
22 let chars: Vec<char> = path.chars().collect();
23 let mut segments = Vec::new();
24 let mut field = String::new();
25 let mut i = 0;
26 let mut saw_any = false;
27
28 let unescape = |chars: &[char], i: &mut usize| -> Result<char, String> {
29 *i += 1;
30 match chars.get(*i) {
31 Some(&c @ ('.' | '[' | ']' | '{' | '}' | '!')) => Ok(c),
32 Some(c) => Err(format!("invalid escape !{c}")),
33 None => Err("trailing ! escape".to_owned()),
34 }
35 };
36
37 while i < chars.len() {
38 let c = chars[i];
39 match c {
40 '.' => {
41 if !saw_any {
42 return Err("empty path segment".to_owned());
43 }
44 segments.push(Segment {
45 field: std::mem::take(&mut field),
46 modifier: None,
47 });
48 saw_any = false;
49 i += 1;
50 }
51 '!' => {
52 field.push(unescape(&chars, &mut i)?);
53 saw_any = true;
54 i += 1;
55 }
56 '[' | '{' => {
57 let (close, is_union_brace) = if c == '[' { (']', false) } else { ('}', true) };
58 let mut inner = String::new();
59 i += 1;
60 loop {
61 match chars.get(i) {
62 None => return Err(format!("unclosed {c}")),
63 Some(&close_c) if close_c == close => break,
64 Some(&'!') => inner.push(unescape(&chars, &mut i)?),
65 Some(&ch) => inner.push(ch),
66 }
67 i += 1;
68 }
69 i += 1; // consume closer
70 let modifier = match (is_union_brace, inner.is_empty()) {
71 (false, true) => Modifier::Elements,
72 (false, false) => Modifier::UnionElements(inner),
73 (true, true) => return Err("empty {} union ref".to_owned()),
74 (true, false) => Modifier::Union(inner),
75 };
76 if field.is_empty() && !saw_any {
77 return Err("modifier without a field".to_owned());
78 }
79 segments.push(Segment {
80 field: std::mem::take(&mut field),
81 modifier: Some(modifier),
82 });
83 saw_any = false;
84 // a modified segment must end the path or be followed by '.'
85 match chars.get(i) {
86 None => break,
87 Some('.') => {
88 i += 1;
89 }
90 Some(other) => {
91 return Err(format!("expected '.' after {c}{close}, got {other:?}"));
92 }
93 }
94 }
95 _ => {
96 field.push(c);
97 saw_any = true;
98 i += 1;
99 }
100 }
101 }
102 if saw_any {
103 segments.push(Segment {
104 field,
105 modifier: None,
106 });
107 }
108 if segments.is_empty() {
109 return Err("empty path".to_owned());
110 }
111 Ok(segments)
112}
113
114pub(crate) fn type_tag(value: &Value) -> Option<&str> {
115 value.get("$type").and_then(Value::as_str)
116}
117
118pub(crate) fn walk_path<'a>(
119 segments: &[Segment],
120 roots: impl IntoIterator<Item = &'a Value>,
121) -> Vec<&'a Value> {
122 let mut nodes: Vec<&Value> = roots.into_iter().collect();
123 for segment in segments {
124 let mut next: Vec<&Value> = nodes
125 .into_iter()
126 .filter_map(|node| node.get(&segment.field))
127 .collect();
128 if let Some(modifier) = &segment.modifier {
129 next = match modifier {
130 Modifier::Elements => next
131 .into_iter()
132 .flat_map(|node| node.as_array().into_iter().flatten())
133 .collect(),
134 Modifier::UnionElements(nsid) => next
135 .into_iter()
136 .flat_map(|node| node.as_array().into_iter().flatten())
137 .filter(|item| type_tag(item) == Some(nsid.as_str()))
138 .collect(),
139 Modifier::Union(nsid) => next
140 .into_iter()
141 .filter(|node| type_tag(node) == Some(nsid.as_str()))
142 .collect(),
143 };
144 }
145 nodes = next;
146 }
147 nodes
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use serde_json::json;
154
155 fn parse_ok(path: &str) -> Vec<Segment> {
156 parse_record_path(path).unwrap_or_else(|e| panic!("{path}: {e}"))
157 }
158
159 #[test]
160 fn parses_field_paths() {
161 assert_eq!(
162 parse_ok("subject.uri"),
163 vec![
164 Segment {
165 field: "subject".into(),
166 modifier: None
167 },
168 Segment {
169 field: "uri".into(),
170 modifier: None
171 },
172 ]
173 );
174 }
175
176 #[test]
177 fn parses_array_descents() {
178 assert_eq!(
179 parse_ok("repos[]"),
180 vec![Segment {
181 field: "repos".into(),
182 modifier: Some(Modifier::Elements)
183 }]
184 );
185 assert_eq!(
186 parse_ok("facets[].features[app.bsky.richtext.facet#mention].did"),
187 vec![
188 Segment {
189 field: "facets".into(),
190 modifier: Some(Modifier::Elements)
191 },
192 Segment {
193 field: "features".into(),
194 modifier: Some(Modifier::UnionElements(
195 "app.bsky.richtext.facet#mention".into()
196 ))
197 },
198 Segment {
199 field: "did".into(),
200 modifier: None
201 },
202 ]
203 );
204 assert_eq!(
205 parse_ok("embed{app.bsky.embed.record}.record.uri"),
206 vec![
207 Segment {
208 field: "embed".into(),
209 modifier: Some(Modifier::Union("app.bsky.embed.record".into()))
210 },
211 Segment {
212 field: "record".into(),
213 modifier: None
214 },
215 Segment {
216 field: "uri".into(),
217 modifier: None
218 },
219 ]
220 );
221 }
222
223 #[test]
224 fn parses_escaped_field_names() {
225 assert_eq!(
226 parse_ok("meta.dot!.name"),
227 vec![Segment {
228 field: "meta".into(),
229 modifier: None
230 }]
231 .into_iter()
232 .chain([Segment {
233 field: "dot.name".into(),
234 modifier: None
235 }])
236 .collect::<Vec<_>>()
237 );
238 assert_eq!(
239 parse_ok("meta.a!!b"),
240 vec![
241 Segment {
242 field: "meta".into(),
243 modifier: None
244 },
245 Segment {
246 field: "a!b".into(),
247 modifier: None
248 },
249 ]
250 );
251 assert_eq!(
252 parse_ok("meta.$unknown"),
253 vec![
254 Segment {
255 field: "meta".into(),
256 modifier: None
257 },
258 Segment {
259 field: "$unknown".into(),
260 modifier: None
261 },
262 ]
263 );
264 }
265
266 #[test]
267 fn rejects_malformed_paths() {
268 for bad in [
269 "", ".", "a..b", "a!", "a!x", "a[", "a[]b", "a{}", "[did]", "a[nsid]b",
270 ] {
271 assert!(parse_record_path(bad).is_err(), "{bad} should fail");
272 }
273 }
274
275 #[test]
276 fn walks_vector_matches() {
277 let doc = json!({
278 "repos": [
279 {"uri": "at://did:plc:a/sh.tangled.repo/x"},
280 {"uri": "at://did:plc:b/sh.tangled.repo/y"},
281 ],
282 "owner": "did:plc:z",
283 });
284 let segs = parse_ok("repos[].uri");
285 let found = walk_path(&segs, [&doc]);
286 assert_eq!(found.len(), 2);
287 }
288
289 #[test]
290 fn walks_union_filters() {
291 let doc = json!({
292 "items": [
293 {"$type": "sh.tangled.repo", "uri": "at://did:plc:a/sh.tangled.repo/x"},
294 {"$type": "sh.tangled.actor.profile", "did": "did:plc:b"},
295 ]
296 });
297 let segs = parse_ok("items[sh.tangled.repo].uri");
298 let found = walk_path(&segs, [&doc]);
299 assert_eq!(found, vec![&json!("at://did:plc:a/sh.tangled.repo/x")]);
300 }
301}