cargo-forward-flags 0.3.1

Generate Cargo feature forwarding declarations
use anyhow::{Result, bail};
use std::collections::BTreeSet;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Directive {
    pub dependency: String,
    pub prefix: String,
    pub exclude: BTreeSet<String>,
    pub line: usize,
    pub source: String,
}

pub fn parse_directive(text: &str, line: usize) -> Result<Directive> {
    let text = text.trim();
    let Some(rest) = text.strip_prefix("<forward") else {
        bail!("expected a `<forward ...>` directive");
    };
    if !rest.chars().next().is_some_and(char::is_whitespace) && !rest.starts_with('>') {
        bail!("malformed forwarding directive; expected `<forward DEPENDENCY ...>`");
    }
    let Some(rest) = rest.strip_suffix('>') else {
        bail!("malformed forwarding directive; expected a closing `>`");
    };
    if rest.contains('>') || rest.contains('<') {
        bail!("malformed forwarding directive; unexpected angle bracket");
    }

    let mut cursor = 0;
    skip_space(rest, &mut cursor);
    let dependency = take_word(rest, &mut cursor);
    if dependency.is_empty() {
        bail!("forwarding directive is missing a dependency key");
    }
    validate_name(dependency, "dependency key")?;

    let mut prefix = None;
    let mut exclude = None;
    loop {
        skip_space(rest, &mut cursor);
        if cursor == rest.len() {
            break;
        }

        let key_start = cursor;
        while cursor < rest.len()
            && !rest.as_bytes()[cursor].is_ascii_whitespace()
            && rest.as_bytes()[cursor] != b'='
        {
            cursor += 1;
        }
        let key = &rest[key_start..cursor];
        if key.is_empty() {
            bail!("malformed attribute in forwarding directive");
        }
        skip_space(rest, &mut cursor);
        if rest.as_bytes().get(cursor) != Some(&b'=') {
            bail!("attribute `{key}` must have a quoted value (for example `{key}=\"value\"`)");
        }
        cursor += 1;
        skip_space(rest, &mut cursor);
        let value = take_quoted(rest, &mut cursor)?;

        match key {
            "prefix" => {
                if prefix.replace(value).is_some() {
                    bail!("duplicate `prefix` attribute");
                }
            }
            "exclude" => {
                if exclude.is_some() {
                    bail!("duplicate `exclude` attribute");
                }
                let mut excluded = BTreeSet::new();
                if !value.is_empty() {
                    for name in value.split(',') {
                        let name = name.trim();
                        if name.is_empty() {
                            bail!("`exclude` contains an empty feature name");
                        }
                        validate_name(name, "excluded feature")?;
                        excluded.insert(name.to_owned());
                    }
                }
                exclude = Some(excluded);
            }
            _ => bail!("unknown forwarding attribute `{key}`"),
        }
    }

    let prefix = prefix.unwrap_or_default();
    if !prefix.is_empty()
        && !prefix
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    {
        bail!("`prefix` must contain only ASCII letters, digits, `_`, and `-`");
    }

    Ok(Directive {
        dependency: dependency.to_owned(),
        prefix,
        exclude: exclude.unwrap_or_default(),
        line,
        source: text.to_owned(),
    })
}

pub fn validate_name(name: &str, description: &str) -> Result<()> {
    if name.is_empty()
        || !name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    {
        bail!("{description} `{name}` is not a valid Cargo feature/dependency name");
    }
    Ok(())
}

fn skip_space(text: &str, cursor: &mut usize) {
    while *cursor < text.len() && text.as_bytes()[*cursor].is_ascii_whitespace() {
        *cursor += 1;
    }
}

fn take_word<'a>(text: &'a str, cursor: &mut usize) -> &'a str {
    let start = *cursor;
    while *cursor < text.len() && !text.as_bytes()[*cursor].is_ascii_whitespace() {
        *cursor += 1;
    }
    &text[start..*cursor]
}

fn take_quoted(text: &str, cursor: &mut usize) -> Result<String> {
    if text.as_bytes().get(*cursor) != Some(&b'"') {
        bail!("attribute values must be double-quoted");
    }
    *cursor += 1;
    let mut value = String::new();
    while *cursor < text.len() {
        let byte = text.as_bytes()[*cursor];
        *cursor += 1;
        match byte {
            b'"' => return Ok(value),
            b'\\' => {
                let Some(escaped) = text.as_bytes().get(*cursor).copied() else {
                    bail!("unterminated escape in attribute value");
                };
                *cursor += 1;
                match escaped {
                    b'\\' => value.push('\\'),
                    b'"' => value.push('"'),
                    _ => bail!("unsupported escape in attribute value; use `\\` or `\"`"),
                }
            }
            byte if byte.is_ascii() => value.push(byte as char),
            _ => {
                let start = *cursor - 1;
                let character = text[start..].chars().next().expect("valid UTF-8");
                value.push(character);
                *cursor = start + character.len_utf8();
            }
        }
    }
    bail!("unterminated quoted attribute value")
}

#[cfg(test)]
mod tests {
    use super::parse_directive;

    #[test]
    fn parses_attributes_in_either_order() {
        let a = parse_directive("<forward serde exclude=\"rc,std\" prefix=\"serde-\">", 3).unwrap();
        let b = parse_directive("<forward serde prefix=\"serde-\" exclude=\"std,rc\">", 3).unwrap();
        assert_eq!(a.dependency, b.dependency);
        assert_eq!(a.prefix, b.prefix);
        assert_eq!(a.exclude, b.exclude);
        assert_eq!(a.dependency, "serde");
        assert!(a.exclude.contains("rc"));
    }

    #[test]
    fn rejects_duplicate_unknown_and_malformed_attributes() {
        for text in [
            "<forward serde prefix=\"a\" prefix=\"b\">",
            "<forward serde unknown=\"x\">",
            "<forward serde prefix=x>",
            "<forward>",
            "<forward serde",
        ] {
            assert!(parse_directive(text, 1).is_err(), "accepted {text}");
        }
    }
}