Skip to main content

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}