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 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 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
91pub 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 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 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 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 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
146pub 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 let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
158
159 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; }
168 let name = &trimmed[name_start..name_end];
169
170 let mut cursor = name_end;
171
172 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 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
196pub 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 #[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 #[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 #[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}