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