1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "kebab-case")]
19pub enum MarkKind {
20 Bold,
22 ItalicAsterisk,
25 ItalicUnderscore,
27 Strike,
29 Underline,
31 Highlight,
33 Code,
35 Link,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct InlineMatch {
43 #[serde(rename = "openIdx")]
45 pub open_idx: usize,
46 #[serde(rename = "closeIdx")]
50 pub close_idx: usize,
51 pub inner: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct LinkMatch {
59 #[serde(rename = "openIdx")]
60 pub open_idx: usize,
61 #[serde(rename = "closeIdx")]
62 pub close_idx: usize,
63 #[serde(rename = "innerText")]
64 pub inner_text: String,
65 pub url: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(untagged)]
71pub enum AnyInlineMatch {
72 Link(LinkMatch),
73 Inline(InlineMatch),
74}
75
76#[must_use]
79pub fn match_inline_mark(
80 kind: MarkKind,
81 block_text: &str,
82 cursor_offset: usize,
83) -> Option<AnyInlineMatch> {
84 let chars: Vec<(usize, char)> = utf16_indexed_chars(block_text);
87 if cursor_offset == 0 {
88 return None;
89 }
90 match kind {
91 MarkKind::Bold => {
92 match_double(&chars, cursor_offset, '*', true).map(AnyInlineMatch::Inline)
93 }
94 MarkKind::Strike => {
95 match_double(&chars, cursor_offset, '~', true).map(AnyInlineMatch::Inline)
96 }
97 MarkKind::Underline => {
98 match_double(&chars, cursor_offset, '_', true).map(AnyInlineMatch::Inline)
99 }
100 MarkKind::Highlight => {
101 match_double(&chars, cursor_offset, '=', true).map(AnyInlineMatch::Inline)
102 }
103 MarkKind::Code => match_code(&chars, cursor_offset).map(AnyInlineMatch::Inline),
104 MarkKind::ItalicAsterisk => {
105 match_single(&chars, cursor_offset, '*').map(AnyInlineMatch::Inline)
106 }
107 MarkKind::ItalicUnderscore => {
108 match_single(&chars, cursor_offset, '_').map(AnyInlineMatch::Inline)
109 }
110 MarkKind::Link => match_link(&chars, cursor_offset).map(AnyInlineMatch::Link),
111 }
112}
113
114fn utf16_indexed_chars(s: &str) -> Vec<(usize, char)> {
118 let mut out = Vec::with_capacity(s.len());
119 let mut units = 0usize;
120 for ch in s.chars() {
121 out.push((units, ch));
122 units += ch.len_utf16();
123 }
124 out.push((units, '\0'));
127 out
128}
129
130fn char_at(chars: &[(usize, char)], utf16_offset: usize) -> Option<char> {
131 chars
132 .iter()
133 .find(|(off, _)| *off == utf16_offset)
134 .map(|(_, c)| *c)
135 .filter(|c| *c != '\0')
136}
137
138fn slice_chars(chars: &[(usize, char)], from: usize, to: usize) -> String {
142 if from >= to {
143 return String::new();
144 }
145 chars
146 .iter()
147 .filter(|(off, c)| *c != '\0' && *off >= from && *off < to)
148 .map(|(_, c)| *c)
149 .collect()
150}
151
152fn last_double_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
156 let mut candidate: Option<usize> = None;
157 for window in chars.windows(2) {
158 let (off_a, ca) = window[0];
159 let (_off_b, cb) = window[1];
160 if cb == '\0' {
161 break;
162 }
163 if off_a + 2 > end_exclusive {
164 break;
165 }
166 if ca == delim && cb == delim {
167 candidate = Some(off_a);
168 }
169 }
170 candidate
171}
172
173fn last_single_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
177 let mut best: Option<usize> = None;
178 for i in (0..chars.len().saturating_sub(1)).rev() {
179 let (off, c) = chars[i];
180 if c == '\0' {
181 continue;
182 }
183 if off >= end_exclusive {
184 continue;
185 }
186 if c != delim {
187 continue;
188 }
189 let prev = if i > 0 { Some(chars[i - 1].1) } else { None };
191 let next = chars.get(i + 1).map(|(_, c)| *c);
192 if prev == Some(delim) || next == Some(delim) {
193 continue;
194 }
195 best = Some(off);
196 break;
197 }
198 best
199}
200
201fn match_double(
205 chars: &[(usize, char)],
206 cursor_offset: usize,
207 delim: char,
208 require_no_inner_dup: bool,
209) -> Option<InlineMatch> {
210 let close_idx = cursor_offset.checked_sub(1)?;
211 if char_at(chars, close_idx)? != delim {
212 return None;
213 }
214 if char_at(chars, close_idx.checked_sub(1)?)? != delim {
215 return None;
216 }
217 let search_end = close_idx - 1; let open_idx = last_double_before(chars, delim, search_end)?;
219 let inner_start = open_idx + 2;
220 let inner_end = close_idx - 1;
221 if inner_start >= inner_end {
222 return None;
223 }
224 let inner = slice_chars(chars, inner_start, inner_end);
225 if inner.is_empty() || inner.contains('\n') {
226 return None;
227 }
228 if require_no_inner_dup && inner.contains(delim) {
229 return None;
230 }
231 Some(InlineMatch {
232 open_idx,
233 close_idx,
234 inner,
235 })
236}
237
238fn match_single(chars: &[(usize, char)], cursor_offset: usize, delim: char) -> Option<InlineMatch> {
241 let close_idx = cursor_offset.checked_sub(1)?;
242 if char_at(chars, close_idx)? != delim {
243 return None;
244 }
245 if let Some(prev_idx) = close_idx.checked_sub(1)
247 && char_at(chars, prev_idx) == Some(delim)
248 {
249 return None;
250 }
251 let open_idx = last_single_before(chars, delim, close_idx)?;
252 let inner_start = open_idx + 1;
253 let inner_end = close_idx;
254 if inner_start >= inner_end {
255 return None;
256 }
257 let inner = slice_chars(chars, inner_start, inner_end);
258 if inner.is_empty() || inner.contains('\n') || inner.contains(delim) {
259 return None;
260 }
261 Some(InlineMatch {
262 open_idx,
263 close_idx,
264 inner,
265 })
266}
267
268fn match_code(chars: &[(usize, char)], cursor_offset: usize) -> Option<InlineMatch> {
271 let close_idx = cursor_offset.checked_sub(1)?;
272 if char_at(chars, close_idx)? != '`' {
273 return None;
274 }
275 let open_idx = {
278 let mut best: Option<usize> = None;
279 for i in (0..chars.len().saturating_sub(1)).rev() {
280 let (off, c) = chars[i];
281 if c == '\0' {
282 continue;
283 }
284 if off >= close_idx {
285 continue;
286 }
287 if c == '`' {
288 best = Some(off);
289 break;
290 }
291 }
292 best?
293 };
294 let inner = slice_chars(chars, open_idx + 1, close_idx);
295 if inner.is_empty() || inner.contains('\n') {
296 return None;
297 }
298 Some(InlineMatch {
299 open_idx,
300 close_idx,
301 inner,
302 })
303}
304
305fn match_link(chars: &[(usize, char)], cursor_offset: usize) -> Option<LinkMatch> {
307 let close_idx = cursor_offset.checked_sub(1)?;
308 if char_at(chars, close_idx)? != ')' {
309 return None;
310 }
311 let mid_idx = {
313 let mut best: Option<usize> = None;
314 for i in (0..chars.len().saturating_sub(1)).rev() {
315 let (off, c) = chars[i];
316 if c == '\0' {
317 continue;
318 }
319 if off >= close_idx {
320 continue;
321 }
322 let next_c = chars.get(i + 1).map(|(_, c)| *c);
323 if c == ']' && next_c == Some('(') {
324 best = Some(off);
325 break;
326 }
327 }
328 best?
329 };
330 let open_idx = {
332 let mut best: Option<usize> = None;
333 for i in (0..chars.len().saturating_sub(1)).rev() {
334 let (off, c) = chars[i];
335 if c == '\0' {
336 continue;
337 }
338 if off >= mid_idx {
339 continue;
340 }
341 if c == '[' {
342 best = Some(off);
343 break;
344 }
345 }
346 best?
347 };
348
349 let inner_text = slice_chars(chars, open_idx + 1, mid_idx);
350 let url = slice_chars(chars, mid_idx + 2, close_idx);
351
352 if inner_text.is_empty() || url.is_empty() {
353 return None;
354 }
355 if inner_text.contains('\n') || url.contains('\n') {
356 return None;
357 }
358 if inner_text.contains('[') || inner_text.contains(']') {
359 return None;
360 }
361 if url.contains('(') || url.contains(')') {
362 return None;
363 }
364
365 Some(LinkMatch {
366 open_idx,
367 close_idx,
368 inner_text,
369 url,
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn inner_of(m: AnyInlineMatch) -> InlineMatch {
378 match m {
379 AnyInlineMatch::Inline(im) => im,
380 AnyInlineMatch::Link(_) => panic!("expected InlineMatch"),
381 }
382 }
383
384 fn link_of(m: AnyInlineMatch) -> LinkMatch {
385 match m {
386 AnyInlineMatch::Link(lm) => lm,
387 AnyInlineMatch::Inline(_) => panic!("expected LinkMatch"),
388 }
389 }
390
391 #[test]
394 fn bold_basic() {
395 let m = inner_of(match_inline_mark(MarkKind::Bold, "**hi**", 6).unwrap());
396 assert_eq!(m.open_idx, 0);
397 assert_eq!(m.close_idx, 5);
398 assert_eq!(m.inner, "hi");
399 }
400
401 #[test]
402 fn bold_with_leading_text() {
403 let m = inner_of(match_inline_mark(MarkKind::Bold, "abc **yo**", 10).unwrap());
404 assert_eq!(m.open_idx, 4);
405 assert_eq!(m.inner, "yo");
406 }
407
408 #[test]
409 fn bold_rejects_empty_inner() {
410 assert!(match_inline_mark(MarkKind::Bold, "****", 4).is_none());
411 }
412
413 #[test]
414 fn bold_rejects_inner_with_asterisk() {
415 assert!(match_inline_mark(MarkKind::Bold, "***foo***", 9).is_none());
416 }
417
418 #[test]
419 fn bold_rejects_multiline() {
420 assert!(match_inline_mark(MarkKind::Bold, "**a\nb**", 7).is_none());
421 }
422
423 #[test]
426 fn italic_asterisk_basic() {
427 let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*x*", 3).unwrap());
428 assert_eq!(m.inner, "x");
429 }
430
431 #[test]
432 fn italic_asterisk_rejects_double_signature() {
433 assert!(match_inline_mark(MarkKind::ItalicAsterisk, "**hi**", 6).is_none());
435 }
436
437 #[test]
438 fn italic_asterisk_skips_double_on_opening_search() {
439 let m = inner_of(
442 match_inline_mark(MarkKind::ItalicAsterisk, "**bold** and *italic*", 21).unwrap(),
443 );
444 assert_eq!(m.inner, "italic");
445 }
446
447 #[test]
450 fn italic_underscore_basic() {
451 let m = inner_of(match_inline_mark(MarkKind::ItalicUnderscore, "_x_", 3).unwrap());
452 assert_eq!(m.inner, "x");
453 }
454
455 #[test]
456 fn italic_underscore_rejects_double_signature() {
457 assert!(match_inline_mark(MarkKind::ItalicUnderscore, "__hi__", 6).is_none());
458 }
459
460 #[test]
463 fn strike_basic() {
464 let m = inner_of(match_inline_mark(MarkKind::Strike, "~~x~~", 5).unwrap());
465 assert_eq!(m.inner, "x");
466 }
467
468 #[test]
469 fn strike_rejects_empty() {
470 assert!(match_inline_mark(MarkKind::Strike, "~~~~", 4).is_none());
471 }
472
473 #[test]
476 fn underline_basic() {
477 let m = inner_of(match_inline_mark(MarkKind::Underline, "__x__", 5).unwrap());
478 assert_eq!(m.inner, "x");
479 }
480
481 #[test]
484 fn highlight_basic() {
485 let m = inner_of(match_inline_mark(MarkKind::Highlight, "==yo==", 6).unwrap());
486 assert_eq!(m.inner, "yo");
487 }
488
489 #[test]
492 fn code_basic() {
493 let m = inner_of(match_inline_mark(MarkKind::Code, "`foo`", 5).unwrap());
494 assert_eq!(m.inner, "foo");
495 }
496
497 #[test]
498 fn code_rejects_empty() {
499 assert!(match_inline_mark(MarkKind::Code, "``", 2).is_none());
500 }
501
502 #[test]
505 fn link_basic() {
506 let m =
507 link_of(match_inline_mark(MarkKind::Link, "[devup](https://devup.kr)", 25).unwrap());
508 assert_eq!(m.inner_text, "devup");
509 assert_eq!(m.url, "https://devup.kr");
510 }
511
512 #[test]
513 fn link_rejects_empty_text_or_url() {
514 assert!(match_inline_mark(MarkKind::Link, "[](url)", 7).is_none());
515 assert!(match_inline_mark(MarkKind::Link, "[text]()", 8).is_none());
516 }
517
518 #[test]
519 fn link_rejects_nested_brackets() {
520 assert!(match_inline_mark(MarkKind::Link, "[te[x]t](url)", 13).is_none());
521 }
522
523 #[test]
524 fn link_rejects_nested_parens_in_url() {
525 assert!(match_inline_mark(MarkKind::Link, "[t](u(r)l)", 10).is_none());
526 }
527
528 #[test]
531 fn all_reject_when_cursor_zero() {
532 assert!(match_inline_mark(MarkKind::Bold, "", 0).is_none());
533 assert!(match_inline_mark(MarkKind::Code, "`", 0).is_none());
534 assert!(match_inline_mark(MarkKind::Link, ")", 0).is_none());
535 }
536
537 #[test]
538 fn utf16_offsets_respected_with_surrogate_pair() {
539 let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*😀*", 4).unwrap());
541 assert_eq!(m.inner, "😀");
542 assert_eq!(m.open_idx, 0);
543 assert_eq!(m.close_idx, 3);
544 }
545}