Skip to main content

nula_core/
parser.rs

1//! Single-pass content parser for Nostr text-bearing events.
2//!
3//! Nostr clients render `kind:1` notes (and a long tail of similar
4//! kinds) by walking the `.content` string and recognising four
5//! syntactic affordances:
6//!
7//! - **NIP-21 `nostr:` URIs** ([NIP-21]) — embedded references to
8//!   profiles, events, addressable coordinates, …;
9//! - **HTTP(S) and other scheme URLs** — to be rendered as
10//!   hyperlinks;
11//! - **`#hashtags`** ([NIP-12]) — folksonomy markers that the
12//!   `t` filter key indexes;
13//! - **`\n` line breaks** — for layout.
14//!
15//! [`NostrParser`] is a single-pass tokeniser that yields the
16//! [`Token`] stream representing those affordances plus the
17//! interspersed plain-text spans. Callers configure which affordances
18//! to recognise via [`NostrParserOptions`]; a disabled affordance
19//! falls through to plain text untouched.
20//!
21//! # Why this exists
22//!
23//! Earlier nula releases shipped two narrowly-scoped scanners:
24//! [`crate::nips::nip27::references_in`] (NIP-21 only) and
25//! [`crate::nips::nip27::tags_from_content`] (tag harvester).
26//! Renderers had to layer their own URL / hashtag detection on top.
27//! This module unifies those concerns behind a single iterator so a
28//! UI layer can drive its rendering loop with one walk over the
29//! source string.
30//!
31//! # Token kinds
32//!
33//! | Variant         | Borrow scope | Spec |
34//! |-----------------|--------------|------|
35//! | [`Token::Text`] | `&'a str`    | n/a  |
36//! | [`Token::Nostr`]| owned [`Nip21`] | NIP-21 |
37//! | [`Token::Url`]  | owned [`Url`]   | n/a (RFC-3986) |
38//! | [`Token::Hashtag`] | `&'a str` (without leading `#`) | NIP-12 |
39//! | [`Token::LineBreak`] | n/a    | n/a |
40//!
41//! # Example
42//!
43//! ```
44//! use nula_core::parser::{NostrParser, NostrParserOptions, Token};
45//!
46//! let parser = NostrParser::new();
47//! let opts = NostrParserOptions::default();
48//! let mut tokens = parser.parse("Visit https://example.com #rust", opts);
49//! assert!(matches!(tokens.next(), Some(Token::Text(_))));
50//! assert!(matches!(tokens.next(), Some(Token::Url(_))));
51//! ```
52//!
53//! [NIP-12]: https://github.com/nostr-protocol/nips/blob/master/12.md
54//! [NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
55
56use bech32::Fe32;
57
58use crate::nips::nip21::{self, Nip21};
59use crate::types::Url;
60
61/// One unit produced by [`NostrParser::parse`].
62///
63/// Lifetimes:
64///
65/// - `Text` and `Hashtag` borrow directly from the input string so
66///   the parser can operate without copying.
67/// - `Nostr` and `Url` are owned because their parsed form requires
68///   an allocation (`Nip21`'s relay-hint vectors, `Url`'s normalised
69///   string).
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum Token<'a> {
73    /// Plain text run that did not match any other token. Empty
74    /// strings are never emitted; the parser collapses zero-length
75    /// gaps between adjacent matches.
76    Text(&'a str),
77    /// A successfully-decoded [NIP-21] `nostr:` URI.
78    ///
79    /// [NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
80    Nostr(Nip21),
81    /// A successfully-parsed URL.
82    Url(Url),
83    /// A `#hashtag` whose body is the slice **without** the leading
84    /// `#`. Empty hashtags (a bare `#`) fall through as text.
85    Hashtag(&'a str),
86    /// A `\n` byte at the current position.
87    LineBreak,
88}
89
90/// Knobs that control which affordances [`NostrParser`] recognises.
91///
92/// Disabled affordances are treated as plain text — the parser still
93/// makes forward progress and never errors.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96#[allow(
97    clippy::struct_excessive_bools,
98    reason = "each toggle maps 1:1 to a distinct NIP affordance; collapsing them into a bitflags enum would obscure the doc-friendly per-field comments"
99)]
100pub struct NostrParserOptions {
101    /// Recognise `nostr:<bech32>` URIs.
102    pub parse_nostr_uris: bool,
103    /// Recognise scheme URLs (`http://`, `https://`, `ftp://`, …).
104    pub parse_urls: bool,
105    /// Recognise `#hashtags` per NIP-12.
106    pub parse_hashtags: bool,
107    /// Emit dedicated [`Token::LineBreak`] for `\n` bytes; when
108    /// `false`, line breaks are folded into the surrounding
109    /// [`Token::Text`] runs.
110    pub emit_line_breaks: bool,
111}
112
113impl Default for NostrParserOptions {
114    fn default() -> Self {
115        Self::all_enabled()
116    }
117}
118
119impl NostrParserOptions {
120    /// Every affordance enabled (the most common configuration).
121    #[must_use]
122    pub const fn all_enabled() -> Self {
123        Self {
124            parse_nostr_uris: true,
125            parse_urls: true,
126            parse_hashtags: true,
127            emit_line_breaks: true,
128        }
129    }
130
131    /// No affordance enabled — the parser yields one
132    /// [`Token::Text`] covering the whole input.
133    #[must_use]
134    pub const fn all_disabled() -> Self {
135        Self {
136            parse_nostr_uris: false,
137            parse_urls: false,
138            parse_hashtags: false,
139            emit_line_breaks: false,
140        }
141    }
142
143    /// Builder helper toggling [`Self::parse_nostr_uris`].
144    #[must_use]
145    pub const fn nostr_uris(mut self, enabled: bool) -> Self {
146        self.parse_nostr_uris = enabled;
147        self
148    }
149
150    /// Builder helper toggling [`Self::parse_urls`].
151    #[must_use]
152    pub const fn urls(mut self, enabled: bool) -> Self {
153        self.parse_urls = enabled;
154        self
155    }
156
157    /// Builder helper toggling [`Self::parse_hashtags`].
158    #[must_use]
159    pub const fn hashtags(mut self, enabled: bool) -> Self {
160        self.parse_hashtags = enabled;
161        self
162    }
163
164    /// Builder helper toggling [`Self::emit_line_breaks`].
165    #[must_use]
166    pub const fn line_breaks(mut self, enabled: bool) -> Self {
167        self.emit_line_breaks = enabled;
168        self
169    }
170}
171
172/// Stateless factory that turns a `&str` into a [`NostrParserIter`].
173///
174/// The parser holds no state itself; it exists so callers can
175/// configure module-wide defaults (currently none) without rebuilding
176/// the iterator at every call site.
177#[derive(Debug, Clone, Copy, Default)]
178pub struct NostrParser;
179
180impl NostrParser {
181    /// Construct a new parser.
182    #[must_use]
183    pub const fn new() -> Self {
184        Self
185    }
186
187    /// Parse `text` into a token stream using `opts`.
188    ///
189    /// The returned iterator borrows from `text` and lives for as
190    /// long as `text` does.
191    #[must_use]
192    #[allow(
193        clippy::unused_self,
194        reason = "kept as a method so a future stateful parser variant can land without breaking callers"
195    )]
196    pub const fn parse(self, text: &str, opts: NostrParserOptions) -> NostrParserIter<'_> {
197        NostrParserIter::new(text, opts)
198    }
199}
200
201/// Iterator yielded by [`NostrParser::parse`].
202#[derive(Debug)]
203pub struct NostrParserIter<'a> {
204    text: &'a str,
205    bytes: &'a [u8],
206    pos: usize,
207    opts: NostrParserOptions,
208    /// A pre-computed match the iterator will emit on the *next*
209    /// `next()` call after first flushing the leading text gap.
210    pending: Option<Match>,
211}
212
213impl<'a> NostrParserIter<'a> {
214    const fn new(text: &'a str, opts: NostrParserOptions) -> Self {
215        Self {
216            text,
217            bytes: text.as_bytes(),
218            pos: 0,
219            opts,
220            pending: None,
221        }
222    }
223}
224
225impl<'a> Iterator for NostrParserIter<'a> {
226    type Item = Token<'a>;
227
228    fn next(&mut self) -> Option<Self::Item> {
229        // 1) Drain any pending typed match queued by the previous
230        //    iteration after we emitted its leading text gap.
231        if let Some(mat) = self.pending.take() {
232            self.pos = mat.end;
233            return Some(self.materialise(mat));
234        }
235
236        // 2) Walk forward looking for the next affordance.
237        if self.pos >= self.bytes.len() {
238            return None;
239        }
240        if let Some(mat) = self.next_match() {
241            if mat.start > self.pos {
242                // Emit the leading text gap; queue the typed
243                // match for the next call.
244                let gap = self.text.get(self.pos..mat.start)?;
245                self.pos = mat.start;
246                self.pending = Some(mat);
247                return Some(Token::Text(gap));
248            }
249            // Match starts here; emit it directly.
250            self.pos = mat.end;
251            return Some(self.materialise(mat));
252        }
253        // No more matches; emit the remainder as one Text run.
254        let rest = self.text.get(self.pos..)?;
255        self.pos = self.bytes.len();
256        Some(Token::Text(rest))
257    }
258}
259
260impl<'a> NostrParserIter<'a> {
261    /// Locate the *first* affordance at or after `self.pos`.
262    fn next_match(&self) -> Option<Match> {
263        let mut search = self.pos;
264        while search < self.bytes.len() {
265            if self.opts.emit_line_breaks && self.bytes.get(search) == Some(&b'\n') {
266                return Some(Match::line_break(search));
267            }
268            if self.opts.parse_hashtags
269                && let Some(mat) = self.try_hashtag(search)
270            {
271                return Some(mat);
272            }
273            if self.opts.parse_nostr_uris
274                && let Some(mat) = self.try_nostr_uri(search)
275            {
276                return Some(mat);
277            }
278            if self.opts.parse_urls
279                && let Some(mat) = self.try_url(search)
280            {
281                return Some(mat);
282            }
283            search += utf8_step(self.text, search);
284        }
285        None
286    }
287
288    fn try_hashtag(&self, start: usize) -> Option<Match> {
289        if self.bytes.get(start)? != &b'#' {
290            return None;
291        }
292        // A hashtag is anchored at start-of-string or directly after
293        // a whitespace byte. Anything else (e.g. `foo#bar`) is text.
294        if start > 0
295            && let Some(prev) = self.bytes.get(start - 1)
296            && !prev.is_ascii_whitespace()
297        {
298            return None;
299        }
300        let mut end = start + 1;
301        while end < self.bytes.len() {
302            let ch = self.text.get(end..)?.chars().next()?;
303            if is_forbidden_hashtag_char(ch) {
304                break;
305            }
306            end += ch.len_utf8();
307        }
308        if end == start + 1 {
309            return None;
310        }
311        Some(Match::hashtag(start, end))
312    }
313
314    fn try_nostr_uri(&self, start: usize) -> Option<Match> {
315        let prefix = nip21::SCHEME_PREFIX.as_bytes(); // "nostr:"
316        if self.bytes.get(start..start + prefix.len()) != Some(prefix) {
317            return None;
318        }
319        let body_start = start + prefix.len();
320        let mut end = body_start;
321        // Greedy bech32 consumption: `Nip21::parse` validates the
322        // checksum so trailing garbage falls back through `Text`.
323        while let Some(&b) = self.bytes.get(end) {
324            if b.is_ascii_lowercase() || b == b'1' || Fe32::from_char(b as char).is_ok() {
325                end += 1;
326            } else {
327                break;
328            }
329        }
330        if end == body_start {
331            return None;
332        }
333        Some(Match::nostr(start, end))
334    }
335
336    fn try_url(&self, start: usize) -> Option<Match> {
337        if !self.bytes.get(start)?.is_ascii_alphabetic() {
338            return None;
339        }
340        let mut after_scheme = start + 1;
341        while let Some(&b) = self.bytes.get(after_scheme) {
342            if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
343                after_scheme += 1;
344            } else {
345                break;
346            }
347        }
348        let separator = b"://";
349        if self.bytes.get(after_scheme..after_scheme + separator.len()) != Some(separator) {
350            return None;
351        }
352        let mut end = after_scheme + separator.len();
353        while let Some(&b) = self.bytes.get(end) {
354            if b.is_ascii_whitespace() || !is_allowed_url_byte(b) {
355                break;
356            }
357            end += 1;
358        }
359        if end <= after_scheme + separator.len() {
360            return None;
361        }
362        // Trim sentence-terminating punctuation.
363        while end > after_scheme + separator.len()
364            && self
365                .bytes
366                .get(end - 1)
367                .copied()
368                .is_some_and(is_url_trailing_punct)
369        {
370            end -= 1;
371        }
372        // Balance an unmatched closing paren.
373        if end > start
374            && self.bytes.get(end - 1) == Some(&b')')
375            && let Some(url_bytes) = self.bytes.get(start..end)
376        {
377            #[allow(
378                clippy::naive_bytecount,
379                reason = "avoid pulling in `bytecount` for two scans of a tiny URL slice"
380            )]
381            let opens = url_bytes.iter().filter(|&&b| b == b'(').count();
382            #[allow(
383                clippy::naive_bytecount,
384                reason = "avoid pulling in `bytecount` for two scans of a tiny URL slice"
385            )]
386            let closes = url_bytes.iter().filter(|&&b| b == b')').count();
387            if closes > opens {
388                end -= 1;
389            }
390        }
391        Some(Match::url(start, end))
392    }
393
394    fn materialise(&self, mat: Match) -> Token<'a> {
395        let Some(slice) = self.text.get(mat.start..mat.end) else {
396            return Token::Text("");
397        };
398        match mat.kind {
399            MatchKind::LineBreak => Token::LineBreak,
400            MatchKind::Hashtag => slice.get(1..).map_or(Token::Text(slice), Token::Hashtag),
401            MatchKind::NostrUri => Nip21::parse(slice).map_or(Token::Text(slice), Token::Nostr),
402            MatchKind::Url => Url::parse(slice).map_or(Token::Text(slice), Token::Url),
403        }
404    }
405}
406
407#[derive(Debug, Clone, Copy)]
408struct Match {
409    kind: MatchKind,
410    start: usize,
411    end: usize,
412}
413
414impl Match {
415    const fn line_break(at: usize) -> Self {
416        Self {
417            kind: MatchKind::LineBreak,
418            start: at,
419            end: at + 1,
420        }
421    }
422    const fn hashtag(start: usize, end: usize) -> Self {
423        Self {
424            kind: MatchKind::Hashtag,
425            start,
426            end,
427        }
428    }
429    const fn nostr(start: usize, end: usize) -> Self {
430        Self {
431            kind: MatchKind::NostrUri,
432            start,
433            end,
434        }
435    }
436    const fn url(start: usize, end: usize) -> Self {
437        Self {
438            kind: MatchKind::Url,
439            start,
440            end,
441        }
442    }
443}
444
445#[derive(Debug, Clone, Copy)]
446enum MatchKind {
447    LineBreak,
448    Hashtag,
449    NostrUri,
450    Url,
451}
452
453/// True when `ch` is a byte the NIP-12 hashtag body cannot include.
454fn is_forbidden_hashtag_char(ch: char) -> bool {
455    if ch.is_whitespace() || ch.is_control() {
456        return true;
457    }
458    matches!(
459        ch,
460        '.' | ','
461            | '!'
462            | '?'
463            | '('
464            | ')'
465            | '['
466            | ']'
467            | '{'
468            | '}'
469            | '"'
470            | '\''
471            | '@'
472            | '#'
473            | ';'
474            | ':'
475            | '&'
476            | '*'
477            | '+'
478            | '='
479            | '<'
480            | '>'
481            | '/'
482            | '\\'
483            | '|'
484            | '^'
485            | '~'
486            | '%'
487            | '$'
488            | '`'
489    )
490}
491
492/// True when `byte` may legitimately appear inside a URL body. Based
493/// on RFC-3986 §unreserved + §sub-delims + a few common reserved
494/// chars (`/?#[]@`) that real-world URLs use.
495const fn is_allowed_url_byte(byte: u8) -> bool {
496    byte.is_ascii_alphanumeric()
497        || matches!(
498            byte,
499            b'-' | b'.'
500                | b'_'
501                | b'~'
502                | b':'
503                | b'/'
504                | b'?'
505                | b'#'
506                | b'['
507                | b']'
508                | b'@'
509                | b'!'
510                | b'$'
511                | b'&'
512                | b'\''
513                | b'('
514                | b')'
515                | b'*'
516                | b'+'
517                | b','
518                | b';'
519                | b'='
520                | b'%'
521        )
522}
523
524/// Punctuation that should be stripped from the tail of a URL when it
525/// is more likely sentence-terminator than URL data.
526const fn is_url_trailing_punct(byte: u8) -> bool {
527    matches!(byte, b'.' | b',' | b';' | b':' | b'!' | b'?' | b']' | b'}')
528}
529
530/// Length of the UTF-8 codepoint at `byte_index` inside `text`.
531///
532/// Falls back to `1` when the index is on a continuation byte (which
533/// is invariant-broken and only reachable in malformed inputs); the
534/// fallback keeps the parser making forward progress instead of
535/// looping.
536fn utf8_step(text: &str, byte_index: usize) -> usize {
537    text[byte_index..].chars().next().map_or(1, char::len_utf8)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::Keys;
544    use crate::nips::nip19::{Nip19Profile, ToBech32};
545
546    fn parse(text: &str) -> Vec<Token<'_>> {
547        NostrParser::new()
548            .parse(text, NostrParserOptions::default())
549            .collect()
550    }
551
552    fn parse_with(text: &str, opts: NostrParserOptions) -> Vec<Token<'_>> {
553        NostrParser::new().parse(text, opts).collect()
554    }
555
556    fn npub_uri(seed: u8) -> (String, crate::PublicKey) {
557        let raw = format!("{seed:0>64}");
558        let keys = Keys::parse(&raw).unwrap();
559        let bech = keys.public_key().to_bech32().unwrap();
560        (format!("nostr:{bech}"), *keys.public_key())
561    }
562
563    #[test]
564    fn empty_input_yields_no_tokens() {
565        assert_eq!(parse(""), Vec::<Token<'_>>::new());
566    }
567
568    #[test]
569    fn pure_text_passthrough() {
570        assert_eq!(parse("hello world"), vec![Token::Text("hello world")]);
571    }
572
573    #[test]
574    fn single_url() {
575        let tokens = parse("https://example.com");
576        assert_eq!(tokens.len(), 1);
577        assert!(matches!(tokens[0], Token::Url(_)));
578    }
579
580    #[test]
581    fn url_in_sentence_with_trailing_dot() {
582        let tokens = parse("Visit https://example.com.");
583        assert_eq!(
584            tokens,
585            vec![
586                Token::Text("Visit "),
587                Token::Url(Url::parse("https://example.com").unwrap()),
588                Token::Text("."),
589            ],
590        );
591    }
592
593    #[test]
594    fn url_with_path_and_query() {
595        let tokens = parse("https://example.com/foo?bar=baz#frag");
596        assert!(matches!(&tokens[..], [Token::Url(_)]));
597    }
598
599    #[test]
600    fn url_strips_trailing_comma_then_emits_text() {
601        let tokens = parse("see https://example.com, then leave");
602        assert_eq!(tokens.len(), 3);
603        assert!(matches!(tokens[1], Token::Url(_)));
604        assert!(matches!(tokens[2], Token::Text(", then leave")));
605    }
606
607    #[test]
608    fn url_balances_unmatched_parenthesis() {
609        let tokens = parse("(https://example.com)");
610        assert_eq!(
611            tokens,
612            vec![
613                Token::Text("("),
614                Token::Url(Url::parse("https://example.com").unwrap()),
615                Token::Text(")"),
616            ],
617        );
618    }
619
620    #[test]
621    fn url_keeps_balanced_parentheses() {
622        let tokens = parse("https://en.wikipedia.org/wiki/Rust_(programming_language)");
623        assert!(matches!(&tokens[..], [Token::Url(_)]));
624    }
625
626    #[test]
627    fn ftp_scheme_recognised() {
628        let tokens = parse("ftp://files.example.com/x");
629        assert!(matches!(&tokens[..], [Token::Url(_)]));
630    }
631
632    #[test]
633    fn hashtag_at_start() {
634        let tokens = parse("#rust is fun");
635        assert_eq!(tokens, vec![Token::Hashtag("rust"), Token::Text(" is fun")],);
636    }
637
638    #[test]
639    fn hashtag_after_whitespace() {
640        let tokens = parse("hello #nostr");
641        assert_eq!(tokens, vec![Token::Text("hello "), Token::Hashtag("nostr")],);
642    }
643
644    #[test]
645    fn hashtag_after_letter_is_text() {
646        let tokens = parse("foo#bar");
647        assert_eq!(tokens, vec![Token::Text("foo#bar")]);
648    }
649
650    #[test]
651    fn bare_hash_is_text() {
652        let tokens = parse("just a # symbol");
653        assert_eq!(tokens, vec![Token::Text("just a # symbol")]);
654    }
655
656    #[test]
657    fn hashtag_terminates_at_punctuation() {
658        let tokens = parse("#tag, more");
659        assert_eq!(tokens, vec![Token::Hashtag("tag"), Token::Text(", more")],);
660    }
661
662    #[test]
663    fn hashtag_with_unicode_body() {
664        let tokens = parse("#日本語ok");
665        assert_eq!(tokens, vec![Token::Hashtag("日本語ok")]);
666    }
667
668    #[test]
669    fn nostr_npub_uri() {
670        let (uri, pk) = npub_uri(3);
671        let tokens = parse(&uri);
672        assert_eq!(tokens.len(), 1);
673        match &tokens[0] {
674            Token::Nostr(Nip21::Pubkey(p)) => assert_eq!(*p, pk),
675            other => panic!("expected Token::Nostr(Pubkey), got {other:?}"),
676        }
677    }
678
679    #[test]
680    fn nostr_uri_inside_sentence() {
681        let (uri, _) = npub_uri(5);
682        let text = format!("hi {uri}, ok?");
683        let tokens = parse(&text);
684        assert_eq!(tokens.len(), 3);
685        assert!(matches!(tokens[0], Token::Text("hi ")));
686        assert!(matches!(tokens[1], Token::Nostr(Nip21::Pubkey(_))));
687        assert!(matches!(tokens[2], Token::Text(", ok?")));
688    }
689
690    #[test]
691    fn nsec_uri_falls_through_as_text() {
692        // NIP-21 forbids `nsec` URIs; the parser refuses to surface
693        // them as Token::Nostr but must not panic — they fall back to
694        // a plain Text run.
695        let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
696            .unwrap();
697        let bech = keys.secret_key().to_bech32().unwrap();
698        let text = format!("leak: nostr:{bech}");
699        let tokens = parse(&text);
700        assert!(
701            tokens.iter().all(|t| !matches!(t, Token::Nostr(_))),
702            "nsec must NOT be surfaced as a Nostr token: {tokens:?}"
703        );
704    }
705
706    #[test]
707    fn line_break_emitted_when_enabled() {
708        let tokens = parse("a\nb");
709        assert_eq!(
710            tokens,
711            vec![Token::Text("a"), Token::LineBreak, Token::Text("b")],
712        );
713    }
714
715    #[test]
716    fn line_break_can_be_disabled() {
717        let opts = NostrParserOptions::default().line_breaks(false);
718        let tokens = parse_with("a\nb", opts);
719        assert_eq!(tokens, vec![Token::Text("a\nb")]);
720    }
721
722    #[test]
723    fn hashtag_disabled_falls_through() {
724        let opts = NostrParserOptions::default().hashtags(false);
725        let tokens = parse_with("#rust", opts);
726        assert_eq!(tokens, vec![Token::Text("#rust")]);
727    }
728
729    #[test]
730    fn url_disabled_falls_through() {
731        let opts = NostrParserOptions::default().urls(false);
732        let tokens = parse_with("see https://example.com.", opts);
733        assert_eq!(tokens, vec![Token::Text("see https://example.com.")]);
734    }
735
736    #[test]
737    fn nostr_uri_disabled_falls_through() {
738        let (uri, _) = npub_uri(11);
739        let opts = NostrParserOptions::default().nostr_uris(false);
740        let tokens = parse_with(&uri, opts);
741        assert_eq!(tokens.len(), 1);
742        assert!(matches!(tokens[0], Token::Text(_)));
743    }
744
745    #[test]
746    fn all_disabled_emits_one_text_run() {
747        let tokens = parse_with(
748            "Hello https://example.com #rust nostr:npub1abc",
749            NostrParserOptions::all_disabled(),
750        );
751        assert_eq!(tokens.len(), 1);
752        assert!(matches!(tokens[0], Token::Text(_)));
753    }
754
755    #[test]
756    fn mixed_url_hashtag_and_nostr_uri() {
757        let (uri, _) = npub_uri(9);
758        let text = format!("Check {uri} via https://relay.example #intro");
759        let tokens = parse(&text);
760        assert!(tokens.len() >= 4);
761        // First non-text token should be the nostr URI.
762        let nostr_idx = tokens
763            .iter()
764            .position(|t| matches!(t, Token::Nostr(_)))
765            .unwrap();
766        let url_idx = tokens
767            .iter()
768            .position(|t| matches!(t, Token::Url(_)))
769            .unwrap();
770        let hash_idx = tokens
771            .iter()
772            .position(|t| matches!(t, Token::Hashtag(_)))
773            .unwrap();
774        assert!(nostr_idx < url_idx && url_idx < hash_idx);
775    }
776
777    #[test]
778    fn multiline_input_yields_line_breaks_between_runs() {
779        let tokens = parse("first\n#tag\nlast");
780        assert_eq!(
781            tokens,
782            vec![
783                Token::Text("first"),
784                Token::LineBreak,
785                Token::Hashtag("tag"),
786                Token::LineBreak,
787                Token::Text("last"),
788            ],
789        );
790    }
791
792    #[test]
793    fn emoji_inside_text_does_not_break_parser() {
794        let tokens = parse("hello 🚀 world");
795        assert_eq!(tokens, vec![Token::Text("hello 🚀 world")]);
796    }
797
798    #[test]
799    fn emoji_directly_before_hashtag() {
800        let tokens = parse("🚀 #moon");
801        assert_eq!(tokens, vec![Token::Text("🚀 "), Token::Hashtag("moon")],);
802    }
803
804    #[test]
805    fn nostr_uri_at_end_of_input() {
806        let (uri, _) = npub_uri(13);
807        let text = format!("ping {uri}");
808        let tokens = parse(&text);
809        assert_eq!(tokens.len(), 2);
810        assert!(matches!(tokens[1], Token::Nostr(_)));
811    }
812
813    #[test]
814    fn malformed_nostr_uri_falls_through() {
815        let tokens = parse("nostr:notbech32 abc");
816        assert!(
817            tokens.iter().all(|t| !matches!(t, Token::Nostr(_))),
818            "garbage bech32 must not produce a Nostr token: {tokens:?}",
819        );
820    }
821
822    #[test]
823    fn many_repeated_hashtags() {
824        let tokens = parse("#a #b #c");
825        let hashtags: Vec<_> = tokens
826            .iter()
827            .filter_map(|t| match t {
828                Token::Hashtag(s) => Some(*s),
829                _ => None,
830            })
831            .collect();
832        assert_eq!(hashtags, vec!["a", "b", "c"]);
833    }
834
835    #[test]
836    fn nprofile_uri_is_recognised() {
837        let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000017")
838            .unwrap();
839        let profile = Nip19Profile::new(*keys.public_key(), std::iter::empty());
840        let bech = profile.to_bech32().unwrap();
841        let uri = format!("nostr:{bech}");
842        let tokens = parse(&uri);
843        assert_eq!(tokens.len(), 1);
844        assert!(matches!(tokens[0], Token::Nostr(Nip21::Profile(_))));
845    }
846
847    #[test]
848    fn iterator_is_lazy() {
849        // The parser must not materialise the whole stream eagerly.
850        let mut iter = NostrParser::new().parse("alpha #beta gamma", NostrParserOptions::default());
851        // Pull one token, then drop the iterator implicitly at end
852        // of scope. The smoke check ensures partial consumption is
853        // safe and the iterator does not allocate eagerly.
854        let _first = iter.next();
855    }
856
857    #[test]
858    fn url_followed_by_newline_is_split() {
859        let tokens = parse("https://example.com\nnext");
860        assert!(matches!(tokens[0], Token::Url(_)));
861        assert!(matches!(tokens[1], Token::LineBreak));
862        assert!(matches!(tokens[2], Token::Text("next")));
863    }
864
865    #[test]
866    fn hashtag_at_end_of_input() {
867        let tokens = parse("ending with #tag");
868        assert_eq!(tokens.len(), 2);
869        assert!(matches!(tokens[1], Token::Hashtag("tag")));
870    }
871
872    #[test]
873    fn forbidden_hashtag_chars_terminate_body() {
874        for forbidden in &[".", ",", "!", "?", "(", ")", "/", ";", ":"] {
875            let text = format!("#abc{forbidden}rest");
876            let tokens = parse(&text);
877            let body = tokens
878                .iter()
879                .find_map(|t| match t {
880                    Token::Hashtag(s) => Some(*s),
881                    _ => None,
882                })
883                .unwrap_or_else(|| panic!("hashtag must split at {forbidden}: {tokens:?}"));
884            assert_eq!(body, "abc", "split-on `{forbidden}` failed");
885        }
886    }
887
888    #[test]
889    fn newline_before_hashtag() {
890        let tokens = parse("\n#start");
891        assert_eq!(tokens, vec![Token::LineBreak, Token::Hashtag("start")],);
892    }
893
894    #[test]
895    fn double_newline_emits_two_line_breaks() {
896        let tokens = parse("a\n\nb");
897        assert_eq!(
898            tokens,
899            vec![
900                Token::Text("a"),
901                Token::LineBreak,
902                Token::LineBreak,
903                Token::Text("b"),
904            ],
905        );
906    }
907}