subplot 0.14.0

tools for specifying, documenting, and implementing automated acceptance tests for systems and software
Documentation
//! Attributes to a fenced code block.
//!
//! These are inspired by the Pandoc [`fenced_code_attributes`
//! extension](https://pandoc.org/chunkedhtml-demo/8.5-verbatim-code-blocks.html).

/// An attribute from a fenced code block.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum BlockAttr {
    /// An identifier: `{#foo}`
    Id(String),

    /// A class: `foo` or `{.foo}`
    Class(String),

    /// A key/value pair: `{foo=bar}`
    ///
    /// Note that for now the value must be a single word without quotes.
    KeyValue(String, String),
}

impl BlockAttr {
    /// Parse a string into a vector of block attributes.
    pub fn parse(input: &str) -> Result<Vec<Self>, BlockAttrError> {
        let s = input.trim();
        if let Some(s) = s.strip_prefix('{') {
            if let Some(s) = s.strip_suffix('}') {
                let attrs: Result<Vec<Self>, BlockAttrError> =
                    s.split_ascii_whitespace().map(Self::parse_one).collect();
                let attrs = attrs?;
                if attrs
                    .iter()
                    .filter(|a| matches!(a, BlockAttr::Id(_)))
                    .count()
                    > 1
                {
                    Err(BlockAttrError::MoreThanOneId(input.into()))
                } else {
                    Ok(attrs)
                }
            } else {
                Err(BlockAttrError::NoCloseBrace(s.into()))
            }
        } else {
            let words: Vec<&str> = s.split_ascii_whitespace().collect();
            if words.is_empty() {
                Ok(vec![])
            } else if words.len() == 1 {
                Ok(vec![Self::Class(words[0].into())])
            } else {
                Err(BlockAttrError::Words(s.into()))
            }
        }
    }

    fn parse_one(s: &str) -> Result<Self, BlockAttrError> {
        if let Some(s) = s.strip_prefix('.') {
            Ok(Self::Class(s.into()))
        } else if let Some(s) = s.strip_prefix('#') {
            Ok(Self::Id(s.into()))
        } else if let Some((k, v)) = s.split_once('=') {
            if v.starts_with('"') || v.starts_with('\'') {
                Err(BlockAttrError::QuotedValue(s.into()))
            } else {
                Ok(Self::KeyValue(k.into(), v.into()))
            }
        } else {
            Ok(Self::Class(s.into()))
        }
    }
}

/// Possible errors from parsing fenced code block attributes.
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
pub enum BlockAttrError {
    /// Attributes consist of multiple words.
    #[error("fenced code block attribute has multiple words: {0:?}")]
    Words(String),

    /// Attributes open with a brace, but there is no closing brace.
    #[error("fenced code block attributes lacks close brace: {0:?}")]
    NoCloseBrace(String),

    /// The is more than in identifier attribute.
    #[error("fenced code block has more than one identifier: {0:?}")]
    MoreThanOneId(String),

    /// The value in a key/value pair uses quotes.
    #[error("fenced code block has quoted value: {0:?}")]
    QuotedValue(String),
}

#[cfg(test)]
mod test {
    use crate::blockattr::BlockAttrError;

    use super::BlockAttr;

    #[test]
    fn empty_string() {
        assert_eq!(BlockAttr::parse("").unwrap(), vec![]);
    }

    #[test]
    fn just_word() {
        assert_eq!(
            BlockAttr::parse("file").unwrap(),
            vec![BlockAttr::Class("file".into())]
        );
    }

    #[test]
    fn two_words() {
        assert!(matches!(
            BlockAttr::parse("haskell file"),
            Err(BlockAttrError::Words(_))
        ));
    }

    #[test]
    fn open_brace_without_close() {
        assert!(matches!(
            BlockAttr::parse("{foo"),
            Err(BlockAttrError::NoCloseBrace(_))
        ));
    }

    #[test]
    fn empty_braces() {
        assert_eq!(BlockAttr::parse("{}").unwrap(), vec![]);
    }

    #[test]
    fn two_ids() {
        assert_eq!(
            BlockAttr::parse("{#foo #bar}"),
            Err(BlockAttrError::MoreThanOneId("{#foo #bar}".into()))
        );
    }

    #[test]
    fn parse_one_word() {
        assert_eq!(
            BlockAttr::parse_one("foo"),
            Ok(BlockAttr::Class("foo".into()))
        );
    }

    #[test]
    fn parse_one_dotted_word() {
        assert_eq!(
            BlockAttr::parse_one(".foo"),
            Ok(BlockAttr::Class("foo".into()))
        );
    }

    #[test]
    fn parse_one_id() {
        assert_eq!(
            BlockAttr::parse_one("#foo"),
            Ok(BlockAttr::Id("foo".into()))
        );
    }

    #[test]
    fn parse_one_kv() {
        assert_eq!(
            BlockAttr::parse_one("foo=bar"),
            Ok(BlockAttr::KeyValue("foo".into(), "bar".into()))
        );
    }

    #[test]
    fn parse_one_kv_with_double_quotes() {
        assert!(matches!(
            BlockAttr::parse_one(r#"foo="bar""#),
            Err(BlockAttrError::QuotedValue(_))
        ));
    }

    #[test]
    fn parse_one_kv_with_single_quotes() {
        assert!(matches!(
            BlockAttr::parse_one("foo='bar'"),
            Err(BlockAttrError::QuotedValue(_))
        ));
    }
}