Skip to main content

nula_core/nips/
nip27.rs

1//! [NIP-27] Text note references.
2//!
3//! NIP-27 standardises the practice of embedding NIP-21 `nostr:`
4//! URIs inside the `.content` of text-bearing events (kinds 1, 30023,
5//! …). Clients scan the body at render time, decode the URI, and
6//! augment the UI (hyperlink, inline preview, mention tag).
7//!
8//! # Why this is a differentiation vs upstream
9//!
10//! `rust-nostr/nostr@master` does not ship a NIP-27 module; callers
11//! have to scan with a hand-rolled regex. Reading the spec properly
12//! means juggling three layers:
13//!
14//! 1. finding every `nostr:<bech32>` span in the content,
15//! 2. decoding each span as a [`Nip21`] entity (which already refuses
16//!    `nsec` bodies via [`crate::nips::nip21`]),
17//! 3. optionally producing `p` / `e` / `a` / `q` tags for the
18//!    referenced entities per NIP-27 + NIP-18.
19//!
20//! This module wraps all three:
21//!
22//! - [`references_in`] — byte-range scanner that yields
23//!   `(range, Nip21)` tuples in content order, skipping malformed
24//!   spans without erroring out.
25//! - [`tags_from_content`] — producer of the tag bundle recommended
26//!   by NIP-27 (`p` for profile/pubkey mentions; `q` for event /
27//!   coordinate quotes per NIP-18). Deduplicates identical tags so
28//!   a content that mentions the same pubkey three times only
29//!   carries one `p` tag.
30//!
31//! Spans are matched conservatively: the scanner only consumes
32//! ASCII lowercase alphanumerics after the `nostr:` head, which is
33//! exactly the bech32 character set used by every NIP-19 HRP (`npub`
34//! / `nsec` / `note` / `nprofile` / `nevent` / `naddr`). Anything
35//! else simply fails the downstream [`Nip21::parse`] and is
36//! dropped.
37//!
38//! # See also
39//!
40//! [`crate::parser`] is the unified text tokeniser that recognises
41//! NIP-21 references **plus** URLs, hashtags, and line breaks in a
42//! single pass. Reach for the parser when a UI layer needs to
43//! reconstruct the original content; the helpers in this module are
44//! the right tool when you only care about the embedded NIP-21
45//! references.
46//!
47//! [NIP-27]: https://github.com/nostr-protocol/nips/blob/master/27.md
48
49use std::collections::BTreeSet;
50use std::ops::Range;
51
52use crate::event::{Alphabet, SingleLetterTag, Tag, TagKind};
53use crate::nips::nip21::Nip21;
54
55/// Scheme prefix for NIP-21 URIs.
56const SCHEME: &str = "nostr:";
57
58/// One decoded NIP-21 reference inside an event's content.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Reference {
61    /// Byte-offset span into the source content string.
62    ///
63    /// `&content[range]` yields the full `nostr:<bech32>`
64    /// substring — useful for in-place substitution (e.g. rendering
65    /// `@mattn` over the top of a `nostr:nprofile1…`).
66    pub range: Range<usize>,
67    /// The parsed NIP-21 entity.
68    pub entity: Nip21,
69}
70
71/// Iterate every successfully-parsed NIP-21 reference in `content`.
72///
73/// The scanner is zero-allocation: it only allocates inside
74/// [`Nip21::parse`] for the successfully-decoded entities.
75/// Malformed spans (wrong HRP, truncated body, bad checksum, or the
76/// forbidden `nsec` body) are silently skipped.
77pub fn references_in(content: &str) -> impl Iterator<Item = Reference> + '_ {
78    NostrUriScanner::new(content)
79}
80
81/// Produce the NIP-27 + NIP-18 implicit tag bundle from `content`.
82///
83/// Mapping:
84///
85/// | `Nip21` variant                | Tag(s) emitted                                |
86/// |--------------------------------|-----------------------------------------------|
87/// | `Pubkey` / `Profile`           | `p` (with relay hints when `Profile`)         |
88/// | `EventId` / `Event`            | `q` (NIP-18, with relay + author when known)  |
89/// | `Coordinate`                   | `q` addressable (`["q", <coord>, <relay>]`)   |
90///
91/// Deduplication is by *content-equal* tag value: a note that
92/// mentions the same pubkey twice only emits one `p` tag.
93///
94/// This function is intentionally side-effect-free; higher-level
95/// builders can layer it on top of a pre-populated tag list by
96/// filtering out duplicates of their own choosing.
97#[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            // `content[full.clone()]` is safe: we only consumed ASCII.
187            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            // Unparseable (wrong HRP, bad checksum, nsec refused, …);
196            // loop and keep scanning.
197        }
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    // Bech32 HRP + separator `1` + data is entirely lowercase
209    // alphanumerics. Uppercase is *syntactically* allowed by bech32
210    // but not used by any NIP-19 HRP; we therefore restrict the
211    // scanner to lowercase so adjacency to a capital letter breaks
212    // cleanly (e.g. `nostr:npub1…AND` yields `nostr:npub1…` without
213    // swallowing `AND`).
214    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        // First should be the profile.
251        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        // Byte ranges round-trip.
254        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        // nsec is syntactically valid bech32 but NIP-21 refuses it.
267        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        // Between them lives `" not-a-uri "`: scanner must not
322        // collapse that into a single URI.
323        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}