1use std::collections::BTreeSet;
50use std::ops::Range;
51
52use crate::event::{Alphabet, SingleLetterTag, Tag, TagKind};
53use crate::nips::nip21::Nip21;
54
55const SCHEME: &str = "nostr:";
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Reference {
61 pub range: Range<usize>,
67 pub entity: Nip21,
69}
70
71pub fn references_in(content: &str) -> impl Iterator<Item = Reference> + '_ {
78 NostrUriScanner::new(content)
79}
80
81#[must_use]
98pub fn tags_from_content(content: &str) -> Vec<Tag> {
99 let mut seen: BTreeSet<Vec<String>> = BTreeSet::new();
100 let mut out: Vec<Tag> = Vec::new();
101
102 for r in references_in(content) {
103 for tag in entity_to_tags(&r.entity) {
104 if seen.insert(tag.values().to_vec()) {
105 out.push(tag);
106 }
107 }
108 }
109 out
110}
111
112fn entity_to_tags(entity: &Nip21) -> Vec<Tag> {
113 match entity {
114 Nip21::Pubkey(pk) => vec![Tag::p(*pk)],
115 Nip21::Profile(p) => {
116 let mut values: Vec<String> = Vec::with_capacity(2);
117 values.push(p.public_key.to_hex());
118 if let Some(first) = p.relays.first() {
119 values.push(first.as_str().to_owned());
120 }
121 vec![make_tag(Alphabet::P, values)]
122 }
123 Nip21::EventId(id) => vec![make_tag(Alphabet::Q, [id.to_hex()])],
124 Nip21::Event(e) => {
125 let mut values: Vec<String> = Vec::with_capacity(3);
126 values.push(e.event_id.to_hex());
127 values.push(
128 e.relays
129 .first()
130 .map(|r| r.as_str().to_owned())
131 .unwrap_or_default(),
132 );
133 if let Some(author) = e.author {
134 values.push(author.to_hex());
135 }
136 vec![make_tag(Alphabet::Q, values)]
137 }
138 Nip21::Coordinate(c) => {
139 let mut values: Vec<String> = Vec::with_capacity(2);
140 values.push(c.coordinate.to_wire());
141 if let Some(first) = c.relays.first() {
142 values.push(first.as_str().to_owned());
143 }
144 vec![make_tag(Alphabet::Q, values)]
145 }
146 }
147}
148
149fn make_tag<I, S>(letter: Alphabet, args: I) -> Tag
150where
151 I: IntoIterator<Item = S>,
152 S: Into<String>,
153{
154 let head = TagKind::single_letter(SingleLetterTag::lowercase(letter));
155 Tag::with(&head, args)
156}
157
158struct NostrUriScanner<'a> {
159 content: &'a str,
160 cursor: usize,
161}
162
163impl<'a> NostrUriScanner<'a> {
164 const fn new(content: &'a str) -> Self {
165 Self { content, cursor: 0 }
166 }
167}
168
169impl Iterator for NostrUriScanner<'_> {
170 type Item = Reference;
171
172 fn next(&mut self) -> Option<Self::Item> {
173 let haystack = self.content.as_bytes();
174 loop {
175 let rest = haystack.get(self.cursor..)?;
176 let rel = find_scheme(rest)?;
177 let start = self.cursor + rel;
178 let body_start = start + SCHEME.len();
179 let body_slice = haystack.get(body_start..).unwrap_or(&[]);
180 let body_end = scan_bech32_body(body_slice) + body_start;
181 if body_end == body_start {
182 self.cursor = body_start;
183 continue;
184 }
185 let full = start..body_end;
186 let uri = self.content.get(full.clone())?;
188 self.cursor = body_end;
189 if let Ok(entity) = Nip21::parse(uri) {
190 return Some(Reference {
191 range: full,
192 entity,
193 });
194 }
195 }
198 }
199}
200
201fn find_scheme(haystack: &[u8]) -> Option<usize> {
202 haystack
203 .windows(SCHEME.len())
204 .position(|w| w == SCHEME.as_bytes())
205}
206
207fn scan_bech32_body(bytes: &[u8]) -> usize {
208 bytes
215 .iter()
216 .take_while(|&&b| matches!(b, b'a'..=b'z' | b'0'..=b'9'))
217 .count()
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::Keys;
224 use crate::event::Kind;
225 use crate::key::PublicKey;
226 use crate::nips::nip19::{Nip19Profile, ToBech32};
227
228 fn profile_uri() -> (String, PublicKey) {
229 let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000003")
230 .unwrap();
231 let profile = Nip19Profile::new(*keys.public_key(), std::iter::empty());
232 let bech32 = profile.to_bech32().unwrap();
233 (format!("nostr:{bech32}"), *keys.public_key())
234 }
235
236 fn npub_uri() -> (String, PublicKey) {
237 let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000005")
238 .unwrap();
239 let bech32 = keys.public_key().to_bech32().unwrap();
240 (format!("nostr:{bech32}"), *keys.public_key())
241 }
242
243 #[test]
244 fn scanner_yields_every_valid_reference_in_order() {
245 let (u1, pk1) = profile_uri();
246 let (u2, pk2) = npub_uri();
247 let content = format!("hi {u1} and also {u2}!");
248 let refs: Vec<_> = references_in(&content).collect();
249 assert_eq!(refs.len(), 2);
250 assert!(matches!(&refs[0].entity, Nip21::Profile(p) if p.public_key == pk1));
252 assert!(matches!(refs[1].entity, Nip21::Pubkey(pk) if pk == pk2));
253 assert_eq!(&content[refs[0].range.clone()], u1.as_str());
255 assert_eq!(&content[refs[1].range.clone()], u2.as_str());
256 }
257
258 #[test]
259 fn scanner_skips_malformed_spans() {
260 let content = "nope nostr: bar nostr:not-bech32 nostr:nsec1abc baz";
261 assert!(references_in(content).next().is_none());
262 }
263
264 #[test]
265 fn scanner_skips_disallowed_nsec_bodies() {
266 let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
268 .unwrap();
269 let bech32 = keys.secret_key().to_bech32().unwrap();
270 let content = format!("secret leak: nostr:{bech32}");
271 assert!(references_in(&content).next().is_none());
272 }
273
274 #[test]
275 fn tags_from_content_emits_p_for_pubkey_mentions() {
276 let (uri, pk) = npub_uri();
277 let content = format!("cc {uri}");
278 let tags = tags_from_content(&content);
279 assert_eq!(tags.len(), 1);
280 assert_eq!(tags[0].get(0), Some("p"));
281 assert_eq!(tags[0].get(1), Some(pk.to_hex().as_str()));
282 }
283
284 #[test]
285 fn tags_from_content_deduplicates_repeated_mentions() {
286 let (uri, _) = npub_uri();
287 let content = format!("{uri} {uri} {uri}");
288 let tags = tags_from_content(&content);
289 assert_eq!(tags.len(), 1);
290 }
291
292 #[test]
293 fn tags_from_content_uses_q_for_event_references() {
294 use crate::event::EventId;
295 use crate::nips::nip19::Nip19Event;
296 let id = EventId::parse("0000000000000000000000000000000000000000000000000000000000000001")
297 .unwrap();
298 let ev = Nip19Event::new(id);
299 let bech = ev.to_bech32().unwrap();
300 let content = format!("see nostr:{bech}");
301 let tags = tags_from_content(&content);
302 assert_eq!(tags.len(), 1);
303 assert_eq!(tags[0].get(0), Some("q"));
304 assert_eq!(tags[0].get(1), Some(id.to_hex().as_str()));
305 }
306
307 #[test]
308 fn scanner_tolerates_adjacent_punctuation_and_emoji() {
309 let (uri, _) = profile_uri();
310 let content = format!("look: {uri}. Also emoji 🚀 then {uri}, end.");
311 assert_eq!(references_in(&content).count(), 2);
312 }
313
314 #[test]
315 fn scanner_does_not_cross_whitespace_into_next_token() {
316 let (uri_a, _) = profile_uri();
317 let (uri_b, _) = npub_uri();
318 let content = format!("{uri_a} not-a-uri {uri_b}");
319 let refs: Vec<_> = references_in(&content).collect();
320 assert_eq!(refs.len(), 2);
321 assert!(refs[0].range.end < refs[1].range.start);
324 }
325
326 #[test]
327 fn tags_from_content_emits_q_with_coordinate_and_relay_hint() {
328 use crate::event::Coordinate;
329 use crate::nips::nip19::Nip19Coordinate;
330 use crate::types::RelayUrl;
331
332 let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000009")
333 .unwrap();
334 let coord = Coordinate::new(Kind::new(30_023), *keys.public_key(), "slug");
335 let nip19_coord = Nip19Coordinate::from_coordinate(
336 coord.clone(),
337 [RelayUrl::parse("wss://relay.example/").unwrap()],
338 );
339 let bech = nip19_coord.to_bech32().unwrap();
340 let content = format!("see also nostr:{bech}");
341 let tags = tags_from_content(&content);
342 assert_eq!(tags.len(), 1);
343 assert_eq!(tags[0].get(0), Some("q"));
344 assert_eq!(tags[0].get(1), Some(coord.to_wire().as_str()));
345 assert_eq!(tags[0].get(2), Some("wss://relay.example/"));
346 }
347}