Skip to main content

omni_dev/atlassian/
directive.rs

1//! Generic directive parsers for JFM.
2//!
3//! Supports three levels per the [Generic Directives proposal]:
4//! - Inline: `:name[content]{attrs}` (e.g., `:status[In Progress]{color=blue}`)
5//! - Leaf block: `::name[content]{attrs}` (e.g., `::card[https://example.com]`)
6//! - Container: `:::name{attrs}` open/close fences (e.g., `:::panel{type=info}`)
7//!
8//! [Generic Directives proposal]: https://talk.commonmark.org/t/generic-directives-plugins-syntax/444
9
10use crate::atlassian::attrs::{parse_attrs, Attrs};
11
12/// A parsed directive at any level.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ParsedDirective {
15    /// Directive name (e.g., "panel", "status", "card").
16    pub name: String,
17    /// Content inside `[...]` brackets, if present.
18    pub content: Option<String>,
19    /// Parsed `{key=value}` attributes, if present.
20    pub attrs: Option<Attrs>,
21    /// Byte position after the directive (for inline directives only).
22    pub end_pos: usize,
23}
24
25/// Parses an inline directive `:name[content]{attrs}` starting at `pos`.
26///
27/// The name must be alphabetic (plus hyphens). Content in `[...]` is required.
28/// Attributes in `{...}` are optional.
29///
30/// Returns the parsed directive or `None` if the text doesn't match.
31pub fn try_parse_inline_directive(text: &str, pos: usize) -> Option<ParsedDirective> {
32    let rest = &text[pos..];
33    if !rest.starts_with(':') {
34        return None;
35    }
36
37    // Parse name after ':'
38    let name_start = 1;
39    let name_end = rest[name_start..]
40        .find(|c: char| !c.is_alphanumeric() && c != '-')
41        .map_or(rest.len(), |i| i + name_start);
42
43    if name_end == name_start {
44        return None; // no name
45    }
46    let name = &rest[name_start..name_end];
47
48    // Content in [...] is required for inline directives
49    let after_name = &rest[name_end..];
50    if !after_name.starts_with('[') {
51        return None;
52    }
53    // Find matching ] by counting bracket depth (supports nested brackets
54    // such as :span[[text](url)]{attrs} for span-before-link ordering).
55    let mut depth: usize = 0;
56    let mut bracket_close = None;
57    for (j, ch) in after_name.char_indices() {
58        match ch {
59            '[' => depth += 1,
60            ']' => {
61                depth -= 1;
62                if depth == 0 {
63                    bracket_close = Some(j);
64                    break;
65                }
66            }
67            _ => {}
68        }
69    }
70    let bracket_close = bracket_close?;
71    let content = &after_name[1..bracket_close];
72    let mut cursor = pos + name_end + bracket_close + 1;
73
74    // Optional {attrs}
75    let attrs = if cursor < text.len() && text[cursor..].starts_with('{') {
76        let (end, a) = parse_attrs(text, cursor)?;
77        cursor = end;
78        Some(a)
79    } else {
80        None
81    };
82
83    Some(ParsedDirective {
84        name: name.to_string(),
85        content: Some(content.to_string()),
86        attrs,
87        end_pos: cursor,
88    })
89}
90
91/// Parses a leaf block directive `::name[content]{attrs}` from a full line.
92///
93/// The line must start with `::` (exactly two colons, not three).
94/// Content in `[...]` is optional. Attributes in `{...}` are optional.
95pub fn try_parse_leaf_directive(line: &str) -> Option<ParsedDirective> {
96    let trimmed = line.trim();
97    if !trimmed.starts_with("::") || trimmed.starts_with(":::") {
98        return None;
99    }
100
101    // Parse name after '::'
102    let name_start = 2;
103    let name_end = trimmed[name_start..]
104        .find(|c: char| !c.is_alphanumeric() && c != '-')
105        .map_or(trimmed.len(), |i| i + name_start);
106
107    if name_end == name_start {
108        return None;
109    }
110    let name = &trimmed[name_start..name_end];
111
112    let mut cursor = name_end;
113
114    // Optional content in [...]
115    let content = if cursor < trimmed.len() && trimmed[cursor..].starts_with('[') {
116        let bracket_close = trimmed[cursor..].find(']')? + cursor;
117        let c = &trimmed[cursor + 1..bracket_close];
118        cursor = bracket_close + 1;
119        Some(c.to_string())
120    } else {
121        None
122    };
123
124    // Optional {attrs}
125    let attrs = if cursor < trimmed.len() && trimmed[cursor..].starts_with('{') {
126        let (end, a) = parse_attrs(trimmed, cursor)?;
127        cursor = end;
128        Some(a)
129    } else {
130        None
131    };
132
133    // Remaining text on the line should be empty (or whitespace)
134    if !trimmed[cursor..].trim().is_empty() {
135        return None;
136    }
137
138    Some(ParsedDirective {
139        name: name.to_string(),
140        content,
141        attrs,
142        end_pos: cursor,
143    })
144}
145
146/// Parses a container directive opening fence `:::name{attrs}`.
147///
148/// Returns the parsed directive and the colon count (for matching the close fence).
149/// The line must start with 3+ colons followed by a name.
150pub fn try_parse_container_open(line: &str) -> Option<(ParsedDirective, usize)> {
151    let trimmed = line.trim();
152    if !trimmed.starts_with(":::") {
153        return None;
154    }
155
156    // Count colons
157    let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
158
159    // Parse name after colons
160    let name_start = colon_count;
161    let name_end = trimmed[name_start..]
162        .find(|c: char| !c.is_alphanumeric() && c != '-')
163        .map_or(trimmed.len(), |i| i + name_start);
164
165    if name_end == name_start {
166        return None; // bare `:::` is a close fence, not an open
167    }
168    let name = &trimmed[name_start..name_end];
169
170    let mut cursor = name_end;
171
172    // Optional {attrs}
173    let attrs = if cursor < trimmed.len() && trimmed[cursor..].starts_with('{') {
174        let (end, a) = parse_attrs(trimmed, cursor)?;
175        cursor = end;
176        Some(a)
177    } else {
178        None
179    };
180
181    // Remaining text on the line should be empty
182    if !trimmed[cursor..].trim().is_empty() {
183        return None;
184    }
185
186    let directive = ParsedDirective {
187        name: name.to_string(),
188        content: None,
189        attrs,
190        end_pos: cursor,
191    };
192
193    Some((directive, colon_count))
194}
195
196/// Checks whether a line is a container directive close fence with at least
197/// `min_colons` colons and no name after them.
198pub fn is_container_close(line: &str, min_colons: usize) -> bool {
199    let trimmed = line.trim();
200    let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
201    colon_count >= min_colons && trimmed[colon_count..].trim().is_empty()
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207    use super::*;
208
209    // ── Inline directives ──────────────────────────────────────────
210
211    #[test]
212    fn inline_card_directive() {
213        let d = try_parse_inline_directive(":card[https://example.com]", 0).unwrap();
214        assert_eq!(d.name, "card");
215        assert_eq!(d.content.as_deref(), Some("https://example.com"));
216        assert!(d.attrs.is_none());
217        assert_eq!(d.end_pos, 26);
218    }
219
220    #[test]
221    fn inline_status_with_attrs() {
222        let d = try_parse_inline_directive(":status[In Progress]{color=blue}", 0).unwrap();
223        assert_eq!(d.name, "status");
224        assert_eq!(d.content.as_deref(), Some("In Progress"));
225        assert_eq!(d.attrs.as_ref().unwrap().get("color"), Some("blue"));
226        assert_eq!(d.end_pos, 32);
227    }
228
229    #[test]
230    fn inline_date() {
231        let d = try_parse_inline_directive(":date[2026-04-15]", 0).unwrap();
232        assert_eq!(d.name, "date");
233        assert_eq!(d.content.as_deref(), Some("2026-04-15"));
234    }
235
236    #[test]
237    fn inline_mention_with_attrs() {
238        let d = try_parse_inline_directive(":mention[Alice Smith]{id=5b10ac8d82e05b22cc7d4ef5}", 0)
239            .unwrap();
240        assert_eq!(d.name, "mention");
241        assert_eq!(d.content.as_deref(), Some("Alice Smith"));
242        assert_eq!(
243            d.attrs.as_ref().unwrap().get("id"),
244            Some("5b10ac8d82e05b22cc7d4ef5")
245        );
246    }
247
248    #[test]
249    fn inline_span_with_color() {
250        let d = try_parse_inline_directive(":span[red text]{color=#ff5630}", 0).unwrap();
251        assert_eq!(d.name, "span");
252        assert_eq!(d.content.as_deref(), Some("red text"));
253        assert_eq!(d.attrs.as_ref().unwrap().get("color"), Some("#ff5630"));
254    }
255
256    #[test]
257    fn inline_at_offset() {
258        let text = "See :card[url] here";
259        let d = try_parse_inline_directive(text, 4).unwrap();
260        assert_eq!(d.name, "card");
261        assert_eq!(d.content.as_deref(), Some("url"));
262        assert_eq!(d.end_pos, 14);
263    }
264
265    #[test]
266    fn inline_no_brackets_fails() {
267        assert!(try_parse_inline_directive(":card", 0).is_none());
268    }
269
270    #[test]
271    fn inline_no_name_fails() {
272        assert!(try_parse_inline_directive(":[content]", 0).is_none());
273    }
274
275    #[test]
276    fn inline_not_starting_with_colon() {
277        assert!(try_parse_inline_directive("card[url]", 0).is_none());
278    }
279
280    // ── Leaf block directives ───���──────────────────────────────────
281
282    #[test]
283    fn leaf_card() {
284        let d = try_parse_leaf_directive("::card[https://example.com/browse/PROJ-123]").unwrap();
285        assert_eq!(d.name, "card");
286        assert_eq!(
287            d.content.as_deref(),
288            Some("https://example.com/browse/PROJ-123")
289        );
290    }
291
292    #[test]
293    fn leaf_embed_with_attrs() {
294        let d =
295            try_parse_leaf_directive("::embed[https://figma.com/file/abc]{layout=wide width=80}")
296                .unwrap();
297        assert_eq!(d.name, "embed");
298        assert_eq!(d.content.as_deref(), Some("https://figma.com/file/abc"));
299        assert_eq!(d.attrs.as_ref().unwrap().get("layout"), Some("wide"));
300        assert_eq!(d.attrs.as_ref().unwrap().get("width"), Some("80"));
301    }
302
303    #[test]
304    fn leaf_extension_no_content() {
305        let d =
306            try_parse_leaf_directive("::extension{type=\"com.atlassian.macro\" key=jira-chart}")
307                .unwrap();
308        assert_eq!(d.name, "extension");
309        assert!(d.content.is_none());
310        assert_eq!(
311            d.attrs.as_ref().unwrap().get("type"),
312            Some("com.atlassian.macro")
313        );
314        assert_eq!(d.attrs.as_ref().unwrap().get("key"), Some("jira-chart"));
315    }
316
317    #[test]
318    fn leaf_rejects_triple_colon() {
319        assert!(try_parse_leaf_directive(":::panel{type=info}").is_none());
320    }
321
322    #[test]
323    fn leaf_rejects_trailing_text() {
324        assert!(try_parse_leaf_directive("::card[url] extra").is_none());
325    }
326
327    // ── Container directives ───────────────────────────────────────
328
329    #[test]
330    fn container_panel() {
331        let (d, colons) = try_parse_container_open(":::panel{type=info}").unwrap();
332        assert_eq!(d.name, "panel");
333        assert_eq!(d.attrs.as_ref().unwrap().get("type"), Some("info"));
334        assert_eq!(colons, 3);
335    }
336
337    #[test]
338    fn container_expand_with_title() {
339        let (d, colons) = try_parse_container_open(":::expand{title=\"Click to expand\"}").unwrap();
340        assert_eq!(d.name, "expand");
341        assert_eq!(
342            d.attrs.as_ref().unwrap().get("title"),
343            Some("Click to expand")
344        );
345        assert_eq!(colons, 3);
346    }
347
348    #[test]
349    fn container_four_colons_layout() {
350        let (d, colons) = try_parse_container_open("::::layout").unwrap();
351        assert_eq!(d.name, "layout");
352        assert!(d.attrs.is_none());
353        assert_eq!(colons, 4);
354    }
355
356    #[test]
357    fn container_column_with_width() {
358        let (d, colons) = try_parse_container_open(":::column{width=50}").unwrap();
359        assert_eq!(d.name, "column");
360        assert_eq!(d.attrs.as_ref().unwrap().get("width"), Some("50"));
361        assert_eq!(colons, 3);
362    }
363
364    #[test]
365    fn container_bare_close_is_not_open() {
366        assert!(try_parse_container_open(":::").is_none());
367    }
368
369    #[test]
370    fn container_close_matches_min_colons() {
371        assert!(is_container_close(":::", 3));
372        assert!(is_container_close("::::", 3));
373        assert!(is_container_close("::::", 4));
374        assert!(!is_container_close("::", 3));
375        assert!(!is_container_close(":::panel", 3));
376    }
377
378    #[test]
379    fn container_close_with_whitespace() {
380        assert!(is_container_close(":::  ", 3));
381        assert!(is_container_close("  :::  ", 3));
382    }
383}