parse_author/lib.rs
1//! # parse-author — parse an npm-style author string
2//!
3//! Parse a person string in the `"Name <email> (url)"` form used by `package.json`
4//! `author`/`contributors` fields into its components. A faithful Rust port of the
5//! [`parse-author`](https://www.npmjs.com/package/parse-author) npm package (and the
6//! `author-regex` it is built on). Zero dependencies and `#![no_std]`.
7//!
8//! ```
9//! use parse_author::parse_author;
10//!
11//! let a = parse_author("Jon Schlinkert <jon@example.com> (https://github.com/jonschlinkert)");
12//! assert_eq!(a.name.as_deref(), Some("Jon Schlinkert"));
13//! assert_eq!(a.email.as_deref(), Some("jon@example.com"));
14//! assert_eq!(a.url.as_deref(), Some("https://github.com/jonschlinkert"));
15//!
16//! assert_eq!(parse_author("Jane Doe").name.as_deref(), Some("Jane Doe"));
17//! assert_eq!(parse_author("<me@example.com>").email.as_deref(), Some("me@example.com"));
18//! ```
19
20#![no_std]
21#![doc(html_root_url = "https://docs.rs/parse-author/0.1.0")]
22
23extern crate alloc;
24
25use alloc::string::{String, ToString};
26use alloc::vec::Vec;
27
28// Compile-test the README's examples as part of `cargo test`.
29#[cfg(doctest)]
30#[doc = include_str!("../README.md")]
31struct ReadmeDoctests;
32
33/// A parsed author. Each field is present only when found in the input.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct Author {
36 /// The author's name (everything before the first `<` or `(`).
37 pub name: Option<String>,
38 /// The email address (from the `<…>` part).
39 pub email: Option<String>,
40 /// The URL (from the `(…)` part).
41 pub url: Option<String>,
42}
43
44/// Parse an author string into its [`Author`] parts.
45///
46/// Returns an empty [`Author`] for input with no word characters, or input that does
47/// not match the expected `Name <email> (url)` shape (e.g. an unterminated bracket).
48///
49/// ```
50/// assert_eq!(parse_author::parse_author("").name, None);
51/// assert_eq!(parse_author::parse_author("Foo (https://x.dev)").url.as_deref(), Some("https://x.dev"));
52/// ```
53#[must_use]
54pub fn parse_author(input: &str) -> Author {
55 let mut author = Author::default();
56
57 // Mirrors the reference's `!/\w/.test(str)` guard (ASCII `\w`).
58 if !input
59 .bytes()
60 .any(|b| b.is_ascii_alphanumeric() || b == b'_')
61 {
62 return author;
63 }
64
65 let chars: Vec<char> = input.chars().collect();
66 let len = chars.len();
67 let first_delim = chars.iter().position(|&c| c == '<' || c == '(');
68
69 let name = match first_delim {
70 None => input.trim_matches(is_js_whitespace).to_string(),
71 Some(k) => chars[..k]
72 .iter()
73 .collect::<String>()
74 .trim_matches(is_js_whitespace)
75 .to_string(),
76 };
77
78 // Parse the `<…>` / `(…)` delimiters, mirroring `author-regex`:
79 // …name… \s* ( DELIM )? \s* ( DELIM )* \s* $
80 // Whitespace is allowed before the first delimiter and once before the repeated
81 // run, but not *between* delimiters in that run.
82 let mut delims: Vec<(char, String)> = Vec::new();
83 if let Some(start) = first_delim {
84 let mut i = start;
85 let mut count = 0usize;
86 loop {
87 if i >= len {
88 break;
89 }
90 let open = chars[i];
91 if open != '<' && open != '(' {
92 return author; // junk where a delimiter was expected → no match
93 }
94 i += 1;
95 let content_start = i;
96 while i < len && chars[i] != '>' && chars[i] != ')' {
97 i += 1;
98 }
99 if i >= len {
100 return author; // unterminated delimiter → no match
101 }
102 delims.push((open, chars[content_start..i].iter().collect()));
103 i += 1; // consume the closing bracket
104 count += 1;
105
106 let ws_start = i;
107 while i < len && is_js_whitespace(chars[i]) {
108 i += 1;
109 }
110 if i >= len {
111 break; // trailing whitespace then end → ok
112 }
113 // A second whitespace gap (before the 3rd+ delimiter) is not allowed.
114 if count >= 2 && i > ws_start {
115 return author;
116 }
117 }
118 }
119
120 if !name.is_empty() {
121 author.name = Some(name);
122 }
123 if let Some((open, content)) = delims.first() {
124 assign(&mut author, *open, content);
125 }
126 if delims.len() >= 2 {
127 if let Some((open, content)) = delims.last() {
128 assign(&mut author, *open, content);
129 }
130 }
131 author
132}
133
134/// Whitespace per JavaScript's regex `\s` (no `u` flag): Rust's `White_Space` minus
135/// NEL (`U+0085`) plus the BOM (`U+FEFF`).
136fn is_js_whitespace(c: char) -> bool {
137 (c.is_whitespace() && c != '\u{0085}') || c == '\u{feff}'
138}
139
140/// Assign a delimiter's value to email (`<`) or url (`(`), if non-empty.
141fn assign(author: &mut Author, open: char, content: &str) {
142 if content.is_empty() {
143 return;
144 }
145 match open {
146 '<' => author.email = Some(content.to_string()),
147 '(' => author.url = Some(content.to_string()),
148 _ => {}
149 }
150}