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)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207 use super::*;
208
209 #[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 #[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 #[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}