1use crate::atlassian::attrs::{parse_attrs, Attrs};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ParsedDirective {
15 pub name: String,
17 pub content: Option<String>,
19 pub attrs: Option<Attrs>,
21 pub end_pos: usize,
23}
24
25pub 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 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; }
46 let name = &rest[name_start..name_end];
47
48 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 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
74pub 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 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 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 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 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
129pub 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 let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
141
142 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; }
151 let name = &trimmed[name_start..name_end];
152
153 let mut cursor = name_end;
154
155 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 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
179pub 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 #[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 #[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 #[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}