omni-dev 0.21.0

A powerful Git commit message analysis and amendment toolkit
Documentation
//! Pandoc-style `{key=value}` attribute parser for JFM directives.
//!
//! Parses attribute blocks like `{type=info}`, `{color="bright red"}`,
//! `{underline}`, and `{bg=#DEEBFF colspan=2}`.

use std::collections::BTreeMap;

/// Parsed attributes from a `{...}` block.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Attrs {
    /// Key-value pairs (e.g., `type=info`, `color="#ff5630"`).
    pub map: BTreeMap<String, String>,
    /// Boolean flags without values (e.g., `underline`, `numbered`).
    pub flags: Vec<String>,
    /// Keys that appear more than once (e.g., multiple `annotation-id` values).
    pub(crate) multi: BTreeMap<String, Vec<String>>,
}

impl Attrs {
    /// Returns true if there are no attributes or flags.
    pub fn is_empty(&self) -> bool {
        self.map.is_empty() && self.multi.is_empty() && self.flags.is_empty()
    }

    /// Gets a single value by key (first occurrence for multi-valued keys).
    pub fn get(&self, key: &str) -> Option<&str> {
        self.map.get(key).map(String::as_str).or_else(|| {
            self.multi
                .get(key)
                .and_then(|v| v.first())
                .map(String::as_str)
        })
    }

    /// Returns all values for a key that may appear more than once.
    pub fn get_all(&self, key: &str) -> Vec<&str> {
        if let Some(values) = self.multi.get(key) {
            values.iter().map(String::as_str).collect()
        } else if let Some(value) = self.map.get(key) {
            vec![value.as_str()]
        } else {
            vec![]
        }
    }

    /// Returns true if the given flag is present.
    pub fn has_flag(&self, flag: &str) -> bool {
        self.flags.iter().any(|f| f == flag)
    }

    /// Renders the attributes back to `{key=value flag}` syntax.
    pub fn render(&self) -> String {
        let mut parts = Vec::new();
        for (k, v) in &self.map {
            Self::push_kv(&mut parts, k, v);
        }
        for (k, values) in &self.multi {
            for v in values {
                Self::push_kv(&mut parts, k, v);
            }
        }
        for f in &self.flags {
            parts.push(f.clone());
        }
        format!("{{{}}}", parts.join(" "))
    }

    fn push_kv(parts: &mut Vec<String>, k: &str, v: &str) {
        if v.contains(' ') || v.contains('"') {
            let escaped = v.replace('"', "\\\"");
            parts.push(format!("{k}=\"{escaped}\""));
        } else {
            parts.push(format!("{k}={v}"));
        }
    }
}

/// Parses a `{...}` attribute block starting at `start` in `text`.
///
/// Returns `(end_pos_exclusive, Attrs)` on success, or `None` if the text
/// at `start` does not begin with `{` or is malformed.
pub fn parse_attrs(text: &str, start: usize) -> Option<(usize, Attrs)> {
    let rest = &text[start..];
    if !rest.starts_with('{') {
        return None;
    }

    let close = find_matching_brace(rest)?;
    let inner = &rest[1..close];

    let attrs = parse_inner(inner)?;
    Some((start + close + 1, attrs))
}

/// Finds the closing `}` that matches the opening `{` at position 0.
/// Skips over quoted strings.
fn find_matching_brace(text: &str) -> Option<usize> {
    let mut chars = text[1..].char_indices();
    while let Some((i, ch)) = chars.next() {
        match ch {
            '}' => return Some(i + 1),
            '"' => {
                // Skip quoted string
                loop {
                    match chars.next() {
                        Some((_, '\\')) => {
                            chars.next();
                        }
                        Some((_, '"')) | None => break,
                        _ => {}
                    }
                }
            }
            '\'' => {
                // Skip single-quoted string
                loop {
                    match chars.next() {
                        Some((_, '\\')) => {
                            chars.next();
                        }
                        Some((_, '\'')) | None => break,
                        _ => {}
                    }
                }
            }
            _ => {}
        }
    }
    None
}

/// Parses the content between `{` and `}` into an `Attrs` struct.
fn parse_inner(inner: &str) -> Option<Attrs> {
    let mut attrs = Attrs::default();
    let mut rest = inner.trim();

    while !rest.is_empty() {
        // Parse key (identifier: alphanumeric, hyphens, underscores)
        let key_end = rest
            .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
            .unwrap_or(rest.len());

        if key_end == 0 {
            return None; // unexpected character
        }

        let key = &rest[..key_end];
        rest = rest[key_end..].trim_start();

        if rest.starts_with('=') {
            // Key-value pair (no trim after '=' so empty values like key= are detected)
            rest = &rest[1..];
            let (value, remaining) = parse_value(rest)?;
            if let Some(existing) = attrs.map.remove(key) {
                // Key already seen once — promote to multi-valued.
                attrs
                    .multi
                    .entry(key.to_string())
                    .or_default()
                    .extend([existing, value]);
            } else if let Some(values) = attrs.multi.get_mut(key) {
                // Key already multi-valued — append.
                values.push(value);
            } else {
                attrs.map.insert(key.to_string(), value);
            }
            rest = remaining.trim_start();
        } else {
            // Boolean flag
            attrs.flags.push(key.to_string());
        }
    }

    Some(attrs)
}

/// Parses a value (quoted or unquoted) and returns `(value, remaining_text)`.
fn parse_value(text: &str) -> Option<(String, &str)> {
    if text.starts_with('"') {
        parse_quoted_value(text, '"')
    } else if text.starts_with('\'') {
        parse_quoted_value(text, '\'')
    } else {
        // Unquoted value: runs until whitespace or '}'
        let end = text
            .find(|c: char| c.is_whitespace() || c == '}')
            .unwrap_or(text.len());
        Some((text[..end].to_string(), &text[end..]))
    }
}

/// Parses a quoted value (double or single quotes) with backslash escaping.
fn parse_quoted_value(text: &str, quote: char) -> Option<(String, &str)> {
    let mut chars = text[1..].char_indices();
    let mut value = String::new();

    while let Some((i, ch)) = chars.next() {
        if ch == '\\' {
            if let Some((_, escaped)) = chars.next() {
                value.push(escaped);
            }
        } else if ch == quote {
            return Some((value, &text[i + 2..]));
        } else {
            value.push(ch);
        }
    }
    None // unterminated quote
}

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

    #[test]
    fn simple_key_value() {
        let (end, attrs) = parse_attrs("{type=info}", 0).unwrap();
        assert_eq!(end, 11);
        assert_eq!(attrs.get("type"), Some("info"));
        assert!(attrs.flags.is_empty());
    }

    #[test]
    fn multiple_key_values() {
        let (_, attrs) = parse_attrs("{type=info color=blue}", 0).unwrap();
        assert_eq!(attrs.get("type"), Some("info"));
        assert_eq!(attrs.get("color"), Some("blue"));
    }

    #[test]
    fn quoted_value() {
        let (_, attrs) = parse_attrs("{title=\"Click to expand\"}", 0).unwrap();
        assert_eq!(attrs.get("title"), Some("Click to expand"));
    }

    #[test]
    fn single_quoted_value() {
        let (_, attrs) = parse_attrs("{params='{\"jql\":\"project=PROJ\"}'}", 0).unwrap();
        assert_eq!(attrs.get("params"), Some("{\"jql\":\"project=PROJ\"}"));
    }

    #[test]
    fn boolean_flag() {
        let (_, attrs) = parse_attrs("{underline}", 0).unwrap();
        assert!(attrs.has_flag("underline"));
        assert!(attrs.map.is_empty());
    }

    #[test]
    fn mixed_flags_and_values() {
        let (_, attrs) = parse_attrs("{layout=wide numbered}", 0).unwrap();
        assert_eq!(attrs.get("layout"), Some("wide"));
        assert!(attrs.has_flag("numbered"));
    }

    #[test]
    fn hex_color_value() {
        let (_, attrs) = parse_attrs("{bg=#DEEBFF colspan=2}", 0).unwrap();
        assert_eq!(attrs.get("bg"), Some("#DEEBFF"));
        assert_eq!(attrs.get("colspan"), Some("2"));
    }

    #[test]
    fn offset_start() {
        let text = "some text {type=info}";
        let (end, attrs) = parse_attrs(text, 10).unwrap();
        assert_eq!(end, 21);
        assert_eq!(attrs.get("type"), Some("info"));
    }

    #[test]
    fn no_opening_brace() {
        assert!(parse_attrs("type=info}", 0).is_none());
    }

    #[test]
    fn unclosed_brace() {
        assert!(parse_attrs("{type=info", 0).is_none());
    }

    #[test]
    fn unterminated_quote() {
        assert!(parse_attrs("{title=\"no close}", 0).is_none());
    }

    #[test]
    fn empty_attrs() {
        let (end, attrs) = parse_attrs("{}", 0).unwrap();
        assert_eq!(end, 2);
        assert!(attrs.is_empty());
    }

    #[test]
    fn escaped_quote_in_value() {
        let (_, attrs) = parse_attrs("{title=\"say \\\"hello\\\"\"}", 0).unwrap();
        assert_eq!(attrs.get("title"), Some("say \"hello\""));
    }

    #[test]
    fn render_round_trip() {
        let (_, original) = parse_attrs("{type=info color=blue numbered}", 0).unwrap();
        let rendered = original.render();
        let (_, reparsed) = parse_attrs(&rendered, 0).unwrap();
        assert_eq!(original, reparsed);
    }

    #[test]
    fn render_quoted_value_with_spaces() {
        let (_, attrs) = parse_attrs("{title=\"Click to expand\"}", 0).unwrap();
        let rendered = attrs.render();
        assert_eq!(rendered, "{title=\"Click to expand\"}");
    }

    #[test]
    fn empty_value() {
        // Issue #363: accessLevel= (empty value) should parse as empty string
        let (end, attrs) = parse_attrs("{id=abc accessLevel=}", 0).unwrap();
        assert_eq!(end, 21);
        assert_eq!(attrs.get("id"), Some("abc"));
        assert_eq!(attrs.get("accessLevel"), Some(""));
    }

    #[test]
    fn empty_value_mid_attrs() {
        let (_, attrs) = parse_attrs("{a= b=value}", 0).unwrap();
        assert_eq!(attrs.get("a"), Some(""));
        assert_eq!(attrs.get("b"), Some("value"));
    }

    #[test]
    fn empty_value_render_round_trip() {
        let (_, original) = parse_attrs("{id=abc accessLevel=}", 0).unwrap();
        let rendered = original.render();
        let (_, reparsed) = parse_attrs(&rendered, 0).unwrap();
        assert_eq!(original, reparsed);
    }

    #[test]
    fn trailing_text_after_attrs() {
        let text = "{type=info} and more text";
        let (end, attrs) = parse_attrs(text, 0).unwrap();
        assert_eq!(end, 11);
        assert_eq!(attrs.get("type"), Some("info"));
        assert_eq!(&text[end..], " and more text");
    }

    #[test]
    fn duplicate_keys_parsed_as_multi() {
        // Issue #439: duplicate keys should all be preserved
        let input = r#"{annotation-id="id1" annotation-type=inlineComment annotation-id="id2" annotation-type=inlineComment}"#;
        let (_, attrs) = parse_attrs(input, 0).unwrap();
        let ids = attrs.get_all("annotation-id");
        assert_eq!(ids, vec!["id1", "id2"]);
        let types = attrs.get_all("annotation-type");
        assert_eq!(types, vec!["inlineComment", "inlineComment"]);
    }

    #[test]
    fn duplicate_keys_get_returns_first() {
        let input = "{k=\"first\" k=\"second\"}";
        let (_, attrs) = parse_attrs(input, 0).unwrap();
        assert_eq!(attrs.get("k"), Some("first"));
        assert_eq!(attrs.get_all("k"), vec!["first", "second"]);
    }

    #[test]
    fn three_duplicate_keys() {
        let input = "{x=a x=b x=c}";
        let (_, attrs) = parse_attrs(input, 0).unwrap();
        assert_eq!(attrs.get_all("x"), vec!["a", "b", "c"]);
        assert_eq!(attrs.get("x"), Some("a"));
    }

    #[test]
    fn duplicate_keys_render_round_trip() {
        let input = r#"{annotation-id="id1" annotation-type=inlineComment annotation-id="id2" annotation-type=inlineComment}"#;
        let (_, original) = parse_attrs(input, 0).unwrap();
        let rendered = original.render();
        let (_, reparsed) = parse_attrs(&rendered, 0).unwrap();
        assert_eq!(original, reparsed);
    }

    #[test]
    fn get_all_single_value() {
        let (_, attrs) = parse_attrs("{type=info}", 0).unwrap();
        assert_eq!(attrs.get_all("type"), vec!["info"]);
        assert!(attrs.get_all("missing").is_empty());
    }

    #[test]
    fn mixed_single_and_duplicate_keys() {
        let input = "{underline a=1 a=2 b=3}";
        let (_, attrs) = parse_attrs(input, 0).unwrap();
        assert!(attrs.has_flag("underline"));
        assert_eq!(attrs.get_all("a"), vec!["1", "2"]);
        assert_eq!(attrs.get("b"), Some("3"));
    }
}