Monorepo for Tangled tangled.org
1

Configure Feed

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

core / bobbin / crates / xrpc / src / recordpath.rs
9.1 kB 301 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 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}