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)]
205mod tests {
206    use super::*;
207
208    // ── Inline directives ──────────────────────────────────────────
209
210    #[test]
211    fn inline_card_directive() {
212        let d = try_parse_inline_directive(":card[https://example.com]", 0).unwrap();
213        assert_eq!(d.name, "card");
214        assert_eq!(d.content.as_deref(), Some("https://example.com"));
215        assert!(d.attrs.is_none());
216        assert_eq!(d.end_pos, 26);
217    }
218
219    #[test]
220    fn inline_status_with_attrs() {
221        let d = try_parse_inline_directive(":status[In Progress]{color=blue}", 0).unwrap();
222        assert_eq!(d.name, "status");
223        assert_eq!(d.content.as_deref(), Some("In Progress"));
224        assert_eq!(d.attrs.as_ref().unwrap().get("color"), Some("blue"));
225        assert_eq!(d.end_pos, 32);
226    }
227
228    #[test]
229    fn inline_date() {
230        let d = try_parse_inline_directive(":date[2026-04-15]", 0).unwrap();
231        assert_eq!(d.name, "date");
232        assert_eq!(d.content.as_deref(), Some("2026-04-15"));
233    }
234
235    #[test]
236    fn inline_mention_with_attrs() {
237        let d = try_parse_inline_directive(":mention[Alice Smith]{id=5b10ac8d82e05b22cc7d4ef5}", 0)
238            .unwrap();
239        assert_eq!(d.name, "mention");
240        assert_eq!(d.content.as_deref(), Some("Alice Smith"));
241        assert_eq!(
242            d.attrs.as_ref().unwrap().get("id"),
243            Some("5b10ac8d82e05b22cc7d4ef5")
244        );
245    }
246
247    #[test]
248    fn inline_span_with_color() {
249        let d = try_parse_inline_directive(":span[red text]{color=#ff5630}", 0).unwrap();
250        assert_eq!(d.name, "span");
251        assert_eq!(d.content.as_deref(), Some("red text"));
252        assert_eq!(d.attrs.as_ref().unwrap().get("color"), Some("#ff5630"));
253    }
254
255    #[test]
256    fn inline_at_offset() {
257        let text = "See :card[url] here";
258        let d = try_parse_inline_directive(text, 4).unwrap();
259        assert_eq!(d.name, "card");
260        assert_eq!(d.content.as_deref(), Some("url"));
261        assert_eq!(d.end_pos, 14);
262    }
263
264    #[test]
265    fn inline_no_brackets_fails() {
266        assert!(try_parse_inline_directive(":card", 0).is_none());
267    }
268
269    #[test]
270    fn inline_no_name_fails() {
271        assert!(try_parse_inline_directive(":[content]", 0).is_none());
272    }
273
274    #[test]
275    fn inline_not_starting_with_colon() {
276        assert!(try_parse_inline_directive("card[url]", 0).is_none());
277    }
278
279    // ── Leaf block directives ───���──────────────────────────────────
280
281    #[test]
282    fn leaf_card() {
283        let d = try_parse_leaf_directive("::card[https://example.com/browse/PROJ-123]").unwrap();
284        assert_eq!(d.name, "card");
285        assert_eq!(
286            d.content.as_deref(),
287            Some("https://example.com/browse/PROJ-123")
288        );
289    }
290
291    #[test]
292    fn leaf_embed_with_attrs() {
293        let d =
294            try_parse_leaf_directive("::embed[https://figma.com/file/abc]{layout=wide width=80}")
295                .unwrap();
296        assert_eq!(d.name, "embed");
297        assert_eq!(d.content.as_deref(), Some("https://figma.com/file/abc"));
298        assert_eq!(d.attrs.as_ref().unwrap().get("layout"), Some("wide"));
299        assert_eq!(d.attrs.as_ref().unwrap().get("width"), Some("80"));
300    }
301
302    #[test]
303    fn leaf_extension_no_content() {
304        let d =
305            try_parse_leaf_directive("::extension{type=\"com.atlassian.macro\" key=jira-chart}")
306                .unwrap();
307        assert_eq!(d.name, "extension");
308        assert!(d.content.is_none());
309        assert_eq!(
310            d.attrs.as_ref().unwrap().get("type"),
311            Some("com.atlassian.macro")
312        );
313        assert_eq!(d.attrs.as_ref().unwrap().get("key"), Some("jira-chart"));
314    }
315
316    #[test]
317    fn leaf_rejects_triple_colon() {
318        assert!(try_parse_leaf_directive(":::panel{type=info}").is_none());
319    }
320
321    #[test]
322    fn leaf_rejects_trailing_text() {
323        assert!(try_parse_leaf_directive("::card[url] extra").is_none());
324    }
325
326    // ── Container directives ───────────────────────────────────────
327
328    #[test]
329    fn container_panel() {
330        let (d, colons) = try_parse_container_open(":::panel{type=info}").unwrap();
331        assert_eq!(d.name, "panel");
332        assert_eq!(d.attrs.as_ref().unwrap().get("type"), Some("info"));
333        assert_eq!(colons, 3);
334    }
335
336    #[test]
337    fn container_expand_with_title() {
338        let (d, colons) = try_parse_container_open(":::expand{title=\"Click to expand\"}").unwrap();
339        assert_eq!(d.name, "expand");
340        assert_eq!(
341            d.attrs.as_ref().unwrap().get("title"),
342            Some("Click to expand")
343        );
344        assert_eq!(colons, 3);
345    }
346
347    #[test]
348    fn container_four_colons_layout() {
349        let (d, colons) = try_parse_container_open("::::layout").unwrap();
350        assert_eq!(d.name, "layout");
351        assert!(d.attrs.is_none());
352        assert_eq!(colons, 4);
353    }
354
355    #[test]
356    fn container_column_with_width() {
357        let (d, colons) = try_parse_container_open(":::column{width=50}").unwrap();
358        assert_eq!(d.name, "column");
359        assert_eq!(d.attrs.as_ref().unwrap().get("width"), Some("50"));
360        assert_eq!(colons, 3);
361    }
362
363    #[test]
364    fn container_bare_close_is_not_open() {
365        assert!(try_parse_container_open(":::").is_none());
366    }
367
368    #[test]
369    fn container_close_matches_min_colons() {
370        assert!(is_container_close(":::", 3));
371        assert!(is_container_close("::::", 3));
372        assert!(is_container_close("::::", 4));
373        assert!(!is_container_close("::", 3));
374        assert!(!is_container_close(":::panel", 3));
375    }
376
377    #[test]
378    fn container_close_with_whitespace() {
379        assert!(is_container_close(":::  ", 3));
380        assert!(is_container_close("  :::  ", 3));
381    }
382}