use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MarkKind {
Bold,
ItalicAsterisk,
ItalicUnderscore,
Strike,
Underline,
Highlight,
Code,
Link,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InlineMatch {
#[serde(rename = "openIdx")]
pub open_idx: usize,
#[serde(rename = "closeIdx")]
pub close_idx: usize,
pub inner: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkMatch {
#[serde(rename = "openIdx")]
pub open_idx: usize,
#[serde(rename = "closeIdx")]
pub close_idx: usize,
#[serde(rename = "innerText")]
pub inner_text: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum AnyInlineMatch {
Link(LinkMatch),
Inline(InlineMatch),
}
#[must_use]
pub fn match_inline_mark(
kind: MarkKind,
block_text: &str,
cursor_offset: usize,
) -> Option<AnyInlineMatch> {
let chars: Vec<(usize, char)> = utf16_indexed_chars(block_text);
if cursor_offset == 0 {
return None;
}
match kind {
MarkKind::Bold => {
match_double(&chars, cursor_offset, '*', true).map(AnyInlineMatch::Inline)
}
MarkKind::Strike => {
match_double(&chars, cursor_offset, '~', true).map(AnyInlineMatch::Inline)
}
MarkKind::Underline => {
match_double(&chars, cursor_offset, '_', true).map(AnyInlineMatch::Inline)
}
MarkKind::Highlight => {
match_double(&chars, cursor_offset, '=', true).map(AnyInlineMatch::Inline)
}
MarkKind::Code => match_code(&chars, cursor_offset).map(AnyInlineMatch::Inline),
MarkKind::ItalicAsterisk => {
match_single(&chars, cursor_offset, '*').map(AnyInlineMatch::Inline)
}
MarkKind::ItalicUnderscore => {
match_single(&chars, cursor_offset, '_').map(AnyInlineMatch::Inline)
}
MarkKind::Link => match_link(&chars, cursor_offset).map(AnyInlineMatch::Link),
}
}
fn utf16_indexed_chars(s: &str) -> Vec<(usize, char)> {
let mut out = Vec::with_capacity(s.len());
let mut units = 0usize;
for ch in s.chars() {
out.push((units, ch));
units += ch.len_utf16();
}
out.push((units, '\0'));
out
}
fn char_at(chars: &[(usize, char)], utf16_offset: usize) -> Option<char> {
chars
.iter()
.find(|(off, _)| *off == utf16_offset)
.map(|(_, c)| *c)
.filter(|c| *c != '\0')
}
fn slice_chars(chars: &[(usize, char)], from: usize, to: usize) -> String {
if from >= to {
return String::new();
}
chars
.iter()
.filter(|(off, c)| *c != '\0' && *off >= from && *off < to)
.map(|(_, c)| *c)
.collect()
}
fn last_double_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
let mut candidate: Option<usize> = None;
for window in chars.windows(2) {
let (off_a, ca) = window[0];
let (_off_b, cb) = window[1];
if cb == '\0' {
break;
}
if off_a + 2 > end_exclusive {
break;
}
if ca == delim && cb == delim {
candidate = Some(off_a);
}
}
candidate
}
fn last_single_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
let mut best: Option<usize> = None;
for i in (0..chars.len().saturating_sub(1)).rev() {
let (off, c) = chars[i];
if c == '\0' {
continue;
}
if off >= end_exclusive {
continue;
}
if c != delim {
continue;
}
let prev = if i > 0 { Some(chars[i - 1].1) } else { None };
let next = chars.get(i + 1).map(|(_, c)| *c);
if prev == Some(delim) || next == Some(delim) {
continue;
}
best = Some(off);
break;
}
best
}
fn match_double(
chars: &[(usize, char)],
cursor_offset: usize,
delim: char,
require_no_inner_dup: bool,
) -> Option<InlineMatch> {
let close_idx = cursor_offset.checked_sub(1)?;
if char_at(chars, close_idx)? != delim {
return None;
}
if char_at(chars, close_idx.checked_sub(1)?)? != delim {
return None;
}
let search_end = close_idx - 1; let open_idx = last_double_before(chars, delim, search_end)?;
let inner_start = open_idx + 2;
let inner_end = close_idx - 1;
if inner_start >= inner_end {
return None;
}
let inner = slice_chars(chars, inner_start, inner_end);
if inner.is_empty() || inner.contains('\n') {
return None;
}
if require_no_inner_dup && inner.contains(delim) {
return None;
}
Some(InlineMatch {
open_idx,
close_idx,
inner,
})
}
fn match_single(chars: &[(usize, char)], cursor_offset: usize, delim: char) -> Option<InlineMatch> {
let close_idx = cursor_offset.checked_sub(1)?;
if char_at(chars, close_idx)? != delim {
return None;
}
if let Some(prev_idx) = close_idx.checked_sub(1)
&& char_at(chars, prev_idx) == Some(delim)
{
return None;
}
let open_idx = last_single_before(chars, delim, close_idx)?;
let inner_start = open_idx + 1;
let inner_end = close_idx;
if inner_start >= inner_end {
return None;
}
let inner = slice_chars(chars, inner_start, inner_end);
if inner.is_empty() || inner.contains('\n') || inner.contains(delim) {
return None;
}
Some(InlineMatch {
open_idx,
close_idx,
inner,
})
}
fn match_code(chars: &[(usize, char)], cursor_offset: usize) -> Option<InlineMatch> {
let close_idx = cursor_offset.checked_sub(1)?;
if char_at(chars, close_idx)? != '`' {
return None;
}
let open_idx = {
let mut best: Option<usize> = None;
for i in (0..chars.len().saturating_sub(1)).rev() {
let (off, c) = chars[i];
if c == '\0' {
continue;
}
if off >= close_idx {
continue;
}
if c == '`' {
best = Some(off);
break;
}
}
best?
};
let inner = slice_chars(chars, open_idx + 1, close_idx);
if inner.is_empty() || inner.contains('\n') {
return None;
}
Some(InlineMatch {
open_idx,
close_idx,
inner,
})
}
fn match_link(chars: &[(usize, char)], cursor_offset: usize) -> Option<LinkMatch> {
let close_idx = cursor_offset.checked_sub(1)?;
if char_at(chars, close_idx)? != ')' {
return None;
}
let mid_idx = {
let mut best: Option<usize> = None;
for i in (0..chars.len().saturating_sub(1)).rev() {
let (off, c) = chars[i];
if c == '\0' {
continue;
}
if off >= close_idx {
continue;
}
let next_c = chars.get(i + 1).map(|(_, c)| *c);
if c == ']' && next_c == Some('(') {
best = Some(off);
break;
}
}
best?
};
let open_idx = {
let mut best: Option<usize> = None;
for i in (0..chars.len().saturating_sub(1)).rev() {
let (off, c) = chars[i];
if c == '\0' {
continue;
}
if off >= mid_idx {
continue;
}
if c == '[' {
best = Some(off);
break;
}
}
best?
};
let inner_text = slice_chars(chars, open_idx + 1, mid_idx);
let url = slice_chars(chars, mid_idx + 2, close_idx);
if inner_text.is_empty() || url.is_empty() {
return None;
}
if inner_text.contains('\n') || url.contains('\n') {
return None;
}
if inner_text.contains('[') || inner_text.contains(']') {
return None;
}
if url.contains('(') || url.contains(')') {
return None;
}
Some(LinkMatch {
open_idx,
close_idx,
inner_text,
url,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn inner_of(m: AnyInlineMatch) -> InlineMatch {
match m {
AnyInlineMatch::Inline(im) => im,
AnyInlineMatch::Link(_) => panic!("expected InlineMatch"),
}
}
fn link_of(m: AnyInlineMatch) -> LinkMatch {
match m {
AnyInlineMatch::Link(lm) => lm,
AnyInlineMatch::Inline(_) => panic!("expected LinkMatch"),
}
}
#[test]
fn bold_basic() {
let m = inner_of(match_inline_mark(MarkKind::Bold, "**hi**", 6).unwrap());
assert_eq!(m.open_idx, 0);
assert_eq!(m.close_idx, 5);
assert_eq!(m.inner, "hi");
}
#[test]
fn bold_with_leading_text() {
let m = inner_of(match_inline_mark(MarkKind::Bold, "abc **yo**", 10).unwrap());
assert_eq!(m.open_idx, 4);
assert_eq!(m.inner, "yo");
}
#[test]
fn bold_rejects_empty_inner() {
assert!(match_inline_mark(MarkKind::Bold, "****", 4).is_none());
}
#[test]
fn bold_rejects_inner_with_asterisk() {
assert!(match_inline_mark(MarkKind::Bold, "***foo***", 9).is_none());
}
#[test]
fn bold_rejects_multiline() {
assert!(match_inline_mark(MarkKind::Bold, "**a\nb**", 7).is_none());
}
#[test]
fn italic_asterisk_basic() {
let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*x*", 3).unwrap());
assert_eq!(m.inner, "x");
}
#[test]
fn italic_asterisk_rejects_double_signature() {
assert!(match_inline_mark(MarkKind::ItalicAsterisk, "**hi**", 6).is_none());
}
#[test]
fn italic_asterisk_skips_double_on_opening_search() {
let m = inner_of(
match_inline_mark(MarkKind::ItalicAsterisk, "**bold** and *italic*", 21).unwrap(),
);
assert_eq!(m.inner, "italic");
}
#[test]
fn italic_underscore_basic() {
let m = inner_of(match_inline_mark(MarkKind::ItalicUnderscore, "_x_", 3).unwrap());
assert_eq!(m.inner, "x");
}
#[test]
fn italic_underscore_rejects_double_signature() {
assert!(match_inline_mark(MarkKind::ItalicUnderscore, "__hi__", 6).is_none());
}
#[test]
fn strike_basic() {
let m = inner_of(match_inline_mark(MarkKind::Strike, "~~x~~", 5).unwrap());
assert_eq!(m.inner, "x");
}
#[test]
fn strike_rejects_empty() {
assert!(match_inline_mark(MarkKind::Strike, "~~~~", 4).is_none());
}
#[test]
fn underline_basic() {
let m = inner_of(match_inline_mark(MarkKind::Underline, "__x__", 5).unwrap());
assert_eq!(m.inner, "x");
}
#[test]
fn highlight_basic() {
let m = inner_of(match_inline_mark(MarkKind::Highlight, "==yo==", 6).unwrap());
assert_eq!(m.inner, "yo");
}
#[test]
fn code_basic() {
let m = inner_of(match_inline_mark(MarkKind::Code, "`foo`", 5).unwrap());
assert_eq!(m.inner, "foo");
}
#[test]
fn code_rejects_empty() {
assert!(match_inline_mark(MarkKind::Code, "``", 2).is_none());
}
#[test]
fn link_basic() {
let m =
link_of(match_inline_mark(MarkKind::Link, "[devup](https://devup.kr)", 25).unwrap());
assert_eq!(m.inner_text, "devup");
assert_eq!(m.url, "https://devup.kr");
}
#[test]
fn link_rejects_empty_text_or_url() {
assert!(match_inline_mark(MarkKind::Link, "[](url)", 7).is_none());
assert!(match_inline_mark(MarkKind::Link, "[text]()", 8).is_none());
}
#[test]
fn link_rejects_nested_brackets() {
assert!(match_inline_mark(MarkKind::Link, "[te[x]t](url)", 13).is_none());
}
#[test]
fn link_rejects_nested_parens_in_url() {
assert!(match_inline_mark(MarkKind::Link, "[t](u(r)l)", 10).is_none());
}
#[test]
fn all_reject_when_cursor_zero() {
assert!(match_inline_mark(MarkKind::Bold, "", 0).is_none());
assert!(match_inline_mark(MarkKind::Code, "`", 0).is_none());
assert!(match_inline_mark(MarkKind::Link, ")", 0).is_none());
}
#[test]
fn utf16_offsets_respected_with_surrogate_pair() {
let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*😀*", 4).unwrap());
assert_eq!(m.inner, "😀");
assert_eq!(m.open_idx, 0);
assert_eq!(m.close_idx, 3);
}
}