parse-author 0.1.1

Parse an npm-style author string "Name <email> (url)" into its name, email, and url. A faithful port of the parse-author npm package. Zero dependencies, no_std.
Documentation
//! # parse-author — parse an npm-style author string
//!
//! Parse a person string in the `"Name <email> (url)"` form used by `package.json`
//! `author`/`contributors` fields into its components. A faithful Rust port of the
//! [`parse-author`](https://www.npmjs.com/package/parse-author) npm package (and the
//! `author-regex` it is built on). Zero dependencies and `#![no_std]`.
//!
//! ```
//! use parse_author::parse_author;
//!
//! let a = parse_author("Jon Schlinkert <jon@example.com> (https://github.com/jonschlinkert)");
//! assert_eq!(a.name.as_deref(), Some("Jon Schlinkert"));
//! assert_eq!(a.email.as_deref(), Some("jon@example.com"));
//! assert_eq!(a.url.as_deref(), Some("https://github.com/jonschlinkert"));
//!
//! assert_eq!(parse_author("Jane Doe").name.as_deref(), Some("Jane Doe"));
//! assert_eq!(parse_author("<me@example.com>").email.as_deref(), Some("me@example.com"));
//! ```

#![no_std]
#![doc(html_root_url = "https://docs.rs/parse-author/0.1.0")]

extern crate alloc;

use alloc::string::{String, ToString};
use alloc::vec::Vec;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// A parsed author. Each field is present only when found in the input.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Author {
    /// The author's name (everything before the first `<` or `(`).
    pub name: Option<String>,
    /// The email address (from the `<…>` part).
    pub email: Option<String>,
    /// The URL (from the `(…)` part).
    pub url: Option<String>,
}

/// Parse an author string into its [`Author`] parts.
///
/// Returns an empty [`Author`] for input with no word characters, or input that does
/// not match the expected `Name <email> (url)` shape (e.g. an unterminated bracket).
///
/// ```
/// assert_eq!(parse_author::parse_author("").name, None);
/// assert_eq!(parse_author::parse_author("Foo (https://x.dev)").url.as_deref(), Some("https://x.dev"));
/// ```
#[must_use]
pub fn parse_author(input: &str) -> Author {
    let mut author = Author::default();

    // Mirrors the reference's `!/\w/.test(str)` guard (ASCII `\w`).
    if !input
        .bytes()
        .any(|b| b.is_ascii_alphanumeric() || b == b'_')
    {
        return author;
    }

    let chars: Vec<char> = input.chars().collect();
    let len = chars.len();
    let first_delim = chars.iter().position(|&c| c == '<' || c == '(');

    let name = match first_delim {
        None => input.trim_matches(is_js_whitespace).to_string(),
        Some(k) => chars[..k]
            .iter()
            .collect::<String>()
            .trim_matches(is_js_whitespace)
            .to_string(),
    };

    // Parse the `<…>` / `(…)` delimiters, mirroring `author-regex`:
    //   …name… \s* ( DELIM )? \s* ( DELIM )* \s* $
    // Whitespace is allowed before the first delimiter and once before the repeated
    // run, but not *between* delimiters in that run.
    let mut delims: Vec<(char, String)> = Vec::new();
    if let Some(start) = first_delim {
        let mut i = start;
        let mut count = 0usize;
        loop {
            if i >= len {
                break;
            }
            let open = chars[i];
            if open != '<' && open != '(' {
                return author; // junk where a delimiter was expected → no match
            }
            i += 1;
            let content_start = i;
            while i < len && chars[i] != '>' && chars[i] != ')' {
                i += 1;
            }
            if i >= len {
                return author; // unterminated delimiter → no match
            }
            delims.push((open, chars[content_start..i].iter().collect()));
            i += 1; // consume the closing bracket
            count += 1;

            let ws_start = i;
            while i < len && is_js_whitespace(chars[i]) {
                i += 1;
            }
            if i >= len {
                break; // trailing whitespace then end → ok
            }
            // A second whitespace gap (before the 3rd+ delimiter) is not allowed.
            if count >= 2 && i > ws_start {
                return author;
            }
        }
    }

    if !name.is_empty() {
        author.name = Some(name);
    }
    if let Some((open, content)) = delims.first() {
        assign(&mut author, *open, content);
    }
    if delims.len() >= 2 {
        if let Some((open, content)) = delims.last() {
            assign(&mut author, *open, content);
        }
    }
    author
}

/// Whitespace per JavaScript's regex `\s` (no `u` flag): Rust's `White_Space` minus
/// NEL (`U+0085`) plus the BOM (`U+FEFF`).
fn is_js_whitespace(c: char) -> bool {
    (c.is_whitespace() && c != '\u{0085}') || c == '\u{feff}'
}

/// Assign a delimiter's value to email (`<`) or url (`(`), if non-empty.
fn assign(author: &mut Author, open: char, content: &str) {
    if content.is_empty() {
        return;
    }
    match open {
        '<' => author.email = Some(content.to_string()),
        '(' => author.url = Some(content.to_string()),
        _ => {}
    }
}