1use bech32::Fe32;
57
58use crate::nips::nip21::{self, Nip21};
59use crate::types::Url;
60
61#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum Token<'a> {
73 Text(&'a str),
77 Nostr(Nip21),
81 Url(Url),
83 Hashtag(&'a str),
86 LineBreak,
88}
89
90#[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 pub parse_nostr_uris: bool,
103 pub parse_urls: bool,
105 pub parse_hashtags: bool,
107 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 #[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 #[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 #[must_use]
145 pub const fn nostr_uris(mut self, enabled: bool) -> Self {
146 self.parse_nostr_uris = enabled;
147 self
148 }
149
150 #[must_use]
152 pub const fn urls(mut self, enabled: bool) -> Self {
153 self.parse_urls = enabled;
154 self
155 }
156
157 #[must_use]
159 pub const fn hashtags(mut self, enabled: bool) -> Self {
160 self.parse_hashtags = enabled;
161 self
162 }
163
164 #[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#[derive(Debug, Clone, Copy, Default)]
178pub struct NostrParser;
179
180impl NostrParser {
181 #[must_use]
183 pub const fn new() -> Self {
184 Self
185 }
186
187 #[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#[derive(Debug)]
203pub struct NostrParserIter<'a> {
204 text: &'a str,
205 bytes: &'a [u8],
206 pos: usize,
207 opts: NostrParserOptions,
208 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 if let Some(mat) = self.pending.take() {
232 self.pos = mat.end;
233 return Some(self.materialise(mat));
234 }
235
236 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 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 self.pos = mat.end;
251 return Some(self.materialise(mat));
252 }
253 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 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 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(); 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 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 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 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
453fn 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
492const 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
524const fn is_url_trailing_punct(byte: u8) -> bool {
527 matches!(byte, b'.' | b',' | b';' | b':' | b'!' | b'?' | b']' | b'}')
528}
529
530fn 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 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 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 let mut iter = NostrParser::new().parse("alpha #beta gamma", NostrParserOptions::default());
851 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}