This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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