use crate::vocab::{Emph, Vocab, INLINE_TAGS};
use crate::Repairs;
#[derive(Clone)]
struct Open {
emph: Emph,
at: usize,
run: usize,
ch: char,
guess: bool,
after_space: bool,
before: Option<char>,
split: bool,
}
#[derive(Clone)]
pub(crate) struct Inline {
open: Vec<Open>,
prev: Option<char>,
scratch: String,
code_src: Vec<char>,
pub repairs: Repairs,
after_close: Option<char>,
pub in_cell: bool,
stripped_tags: Vec<u8>,
pub preview: bool,
wrap: String,
}
impl Inline {
pub fn new() -> Self {
Self {
open: Vec::new(),
prev: None,
scratch: String::new(),
code_src: Vec::new(),
repairs: Repairs::default(),
after_close: None,
in_cell: false,
stripped_tags: Vec::new(),
wrap: String::new(),
preview: false,
}
}
pub fn reset(&mut self) {
self.open.clear();
self.prev = None;
self.stripped_tags.clear();
}
fn text_char(&mut self, c: char, out: &mut String, v: &Vocab) {
if v.escapes(c) {
self.repairs.escaped_char += 1;
}
v.escape_char(c, out);
}
pub fn safe_len(&self, out_len: usize) -> usize {
self.open.first().map_or(out_len, |o| o.at)
}
pub fn shift(&mut self, n: usize) {
for o in &mut self.open {
o.at -= n;
}
}
pub fn end_line(&mut self) {
self.prev = Some('\n');
}
pub fn note_raw(&mut self, s: &str) {
if self.open.last().is_some_and(|o| o.emph == Emph::Code) {
self.code_src.extend(s.chars());
}
}
pub fn set_prev(&mut self, c: Option<char>) {
self.prev = c;
}
pub fn is_open(&self) -> bool {
!self.open.is_empty()
}
pub fn render(&mut self, line: &[char], out: &mut String, v: &Vocab) {
let mut i = 0;
while i < line.len() {
if let Some(top) = self.open.last() {
if top.emph == Emph::Code {
let run = if line[i] == '`' { run_len(line, i, '`') } else { 0 };
if run == top.run {
self.close_at(self.open.len() - 1, out, v);
i += run;
} else {
let step = run.max(1);
for k in 0..step {
v.code_char(line[i + k], out);
self.code_src.push(line[i + k]);
}
i += step;
}
continue;
}
}
let c = line[i];
if c == '!' && line.get(i + 1) == Some(&'[') {
if let Some((text, url_from, url_to)) = find_link(line, i + 1) {
self.render_image(&line[text.0..text.1], &line[url_from..url_to], out, v);
i = url_to + 1;
continue;
}
}
if c == '[' {
if let Some((text, url_from, url_to)) = find_link(line, i) {
self.render_link(&line[text.0..text.1], &line[url_from..url_to], out, v);
i = url_to + 1;
continue;
}
}
if c == '<' {
if let Some(step) = self.angle(line, i, out, v) {
i += step;
continue;
}
}
if c == '\\' {
if let Some(&next) = line.get(i + 1) {
if next.is_ascii_punctuation() {
v.literal(next, out);
self.prev = Some(next);
i += 2;
continue;
}
}
}
if c == '`' {
let run = run_len(line, i, '`');
let prev = self.prev_char(line, i);
self.code_src.clear();
self.open.push(Open {
emph: Emph::Code,
at: out.len(),
run,
ch: '`',
guess: false,
after_space: prev.is_none_or(char::is_whitespace),
before: prev,
split: false,
});
i += run;
continue;
}
if !matches!(c, '*' | '_' | '~') {
self.text_char(c, out, v);
i += 1;
continue;
}
let take = run_len(line, i, c);
let take = if c != '~' && take == 3 {
if self.open.iter().any(|o| o.emph == Emph::Italic) {
1
} else if self.open.iter().any(|o| o.emph == Emph::Bold) {
3
} else {
2
}
} else {
take
};
let emph = match (c, take) {
('~', _) => Emph::Strike,
(_, 1) => Emph::Italic,
_ => Emph::Bold,
};
if c == '~' && take < 2 {
self.text_char(c, out, v);
i += 1;
continue;
}
let start = i - line[..i].iter().rev().take_while(|&&x| x == c).count();
let split = start == i && run_len(line, i, c) > take;
let prev = self.prev_char(line, start);
let next = line.get(i + take).copied();
let after_space = prev.is_none_or(char::is_whitespace);
let same = self.open.iter().rposition(|o| o.emph == emph);
let intraword = c == '_' && prev.is_some_and(char::is_alphanumeric);
if intraword && same.is_none() {
for _ in 0..take {
self.text_char(c, out, v);
}
i += take;
continue;
}
let left = can_open(prev, next) && !intraword;
match same {
Some(at) if self.open[at].guess && (!left || !after_space) => {
for _ in 0..take {
self.text_char(c, out, v);
}
}
Some(at) if self.open[at].guess => self.reopen_at(at, out, v, (emph, take, c, after_space, prev, split)),
Some(at) if !after_space => {
self.after_close = next;
self.close_at(at, out, v)
}
Some(at) if left && at + 1 == self.open.len() => {
self.reopen_at(at, out, v, (emph, take, c, after_space, prev, split))
}
Some(_) if left => {}
Some(at) => {
self.after_close = next;
self.close_at(at, out, v)
}
None if left => self.open.push(Open {
emph,
at: out.len(),
run: take,
ch: c,
guess: false,
after_space,
before: prev,
split,
}),
None => self.open.push(Open {
emph,
at: out.len(),
run: take,
ch: c,
guess: true,
after_space,
before: prev,
split,
}),
}
i += take;
}
if let Some(&last) = line.last() {
self.prev = Some(last);
}
}
pub fn finish_block(&mut self, out: &mut String, v: &Vocab) {
while let Some(top) = self.open.last() {
if top.emph == Emph::Code && !self.preview {
self.revert_code_span(out, v);
continue;
}
self.finalize(out, v, false);
}
self.prev = None;
}
fn revert_code_span(&mut self, out: &mut String, v: &Vocab) {
let Some(open) = self.open.pop() else { return };
self.repairs.reverted_code_span += 1;
out.truncate(open.at);
for _ in 0..open.run {
out.push(open.ch);
}
let src = std::mem::take(&mut self.code_src);
self.prev = Some(open.ch);
self.render(&src, out, v);
if self.code_src.is_empty() {
self.code_src = src;
self.code_src.clear();
}
}
fn reopen_at(
&mut self,
at: usize,
out: &mut String,
v: &Vocab,
fresh: (Emph, usize, char, bool, Option<char>, bool),
) {
let (emph, take, c, after_space, before, split) = fresh;
while self.open.len() > at + 1 {
self.finalize(out, v, false);
}
let old = self.open.pop().expect("at 은 유효한 인덱스다");
if old.run == 1 || (old.guess && (old.after_space || old.split)) {
insert_marker(out, old.at, old.ch, old.run, v, &mut self.repairs);
} else {
self.repairs.dropped_marker += 1;
}
self.open.push(Open { emph, at: out.len(), run: take, ch: c, guess: false, after_space, before, split });
}
fn prev_char(&self, line: &[char], i: usize) -> Option<char> {
if i > 0 {
Some(line[i - 1])
} else {
self.prev
}
}
fn close_at(&mut self, at: usize, out: &mut String, v: &Vocab) {
while self.open.len() > at + 1 {
self.finalize(out, v, false);
}
self.finalize(out, v, true);
}
fn finalize(&mut self, out: &mut String, v: &Vocab, matched: bool) {
let Some(open) = self.open.pop() else { return };
if let Emph::Tag(_) = open.emph {
if out.len() > open.at {
out.insert_str(open.at, v.open(open.emph));
out.push_str(v.close(open.emph));
}
return;
}
let empty = out.len() == open.at;
if open.guess || empty {
if open.after_space || open.run == 1 || open.split {
insert_marker(out, open.at, open.ch, open.run, v, &mut self.repairs);
} else {
self.repairs.dropped_marker += 1;
}
return;
}
if !matched {
self.repairs.closed_emphasis += 1;
}
if open.emph == Emph::Code && !v.is_plain() {
let body = &out[open.at..];
if body.len() >= 2
&& body.starts_with(' ')
&& body.ends_with(' ')
&& !body.chars().all(|c| c == ' ')
{
out.truncate(out.len() - 1);
out.remove(open.at);
}
}
if open.emph == Emph::Code && v.line_emphasis() && out[open.at..].contains('\n') {
let mut buf = std::mem::take(&mut self.wrap);
wrap_per_line(out, open.at, ("`", "`"), true, &mut buf);
self.wrap = buf;
return;
}
if open.emph == Emph::Code && v.open(Emph::Code) == "`" {
let body = &out[open.at..];
let longest = longest_run(body, '`');
if longest > 0 {
let pad = body.starts_with('`') || body.ends_with('`');
let mut fence = String::with_capacity(longest + 2);
for _ in 0..longest + 1 {
fence.push('`');
}
if pad {
out.push(' ');
}
out.push_str(&fence);
if pad {
fence.push(' ');
}
out.insert_str(open.at, &fence);
return;
}
}
let after = if matched { self.after_close } else { None };
if v.html_emphasis() && open.emph != Emph::Code && !gfm_pairs(open.before, &out[open.at..], after) {
self.repairs.tag_emphasis += 1;
out.insert_str(open.at, v.open_html(open.emph));
out.push_str(v.close_html(open.emph));
return;
}
if v.joiner_emphasis() && open.emph != Emph::Code {
let (left, right) = gfm_sides(open.before, &out[open.at..], after);
if !(left && right) {
self.repairs.tag_emphasis += 1;
if !right {
out.push(WORD_JOINER);
}
if !left {
out.insert(open.at, WORD_JOINER);
}
}
}
if !v.html_out() && !v.is_plain() && open.emph != Emph::Code {
let written = v.open(open.emph);
if written.chars().count() != open.run || !written.chars().all(|c| c == open.ch) {
self.repairs.converted_marker += 1;
}
}
if v.line_emphasis() && out[open.at..].contains('\n') {
let mut buf = std::mem::take(&mut self.wrap);
wrap_per_line(out, open.at, (v.open(open.emph), v.close(open.emph)), false, &mut buf);
self.wrap = buf;
return;
}
out.insert_str(open.at, v.open(open.emph));
out.push_str(v.close(open.emph));
}
fn angle(&mut self, line: &[char], i: usize, out: &mut String, v: &Vocab) -> Option<usize> {
let rest = &line[i..];
if starts_with(rest, "<!--") {
let end = find_seq(&rest[4..], "-->")? + 4;
self.repairs.stripped_html += 1;
return Some(end + 3);
}
let close = rest.iter().position(|&c| c == '>')?;
if starts_with(&rest[1..], "http://") || starts_with(&rest[1..], "https://") {
let body = &rest[1..close];
let (url, label, bare) = match body.iter().position(|&c| c == '|') {
Some(bar) => (&body[..bar], &body[bar + 1..], false),
None => (body, body, true),
};
if url.iter().any(|c| c.is_whitespace()) {
return None;
}
let mut href = String::with_capacity(url.len());
href.extend(url.iter());
let mut text = std::mem::take(&mut self.scratch);
text.clear();
for &c in label {
if bare {
v.code_char(c, &mut text);
} else {
self.text_char(c, &mut text, v);
}
}
if !bare && !v.html_out() && !v.is_plain() {
self.repairs.converted_marker += 1;
}
v.link(&text, &href, out);
self.scratch = text;
self.prev = Some('>');
return Some(close + 1);
}
let closing = rest.get(1) == Some(&'/');
let name_at = if closing { 2 } else { 1 };
let mut j = name_at;
while j < close && rest[j].is_ascii_alphanumeric() {
j += 1;
}
let name = &rest[name_at..j];
if name.is_empty() || !(rest[j] == '>' || rest[j] == '/' || rest[j].is_whitespace()) {
return None;
}
if !is_known_tag(name) {
return None;
}
let tag = inline_tag(name);
let br = eq_ignore_case(name, "br");
if (v.is_html() || v.html_emphasis()) && (tag.is_some() || br) {
let at_line_start = !self.in_cell && self.prev_char(line, i).is_none_or(|c| c == '\n');
let tag_only_line = renders_empty(&rest[close + 1..], v);
let github_start = !v.is_html() && at_line_start && tag_only_line;
if closing && !v.is_html() && tag.is_some() && self.stripped_tags.last() == tag.as_ref() {
self.stripped_tags.pop();
} else if github_start {
if let (false, Some(t)) = (closing, tag) {
self.stripped_tags.push(t);
}
} else {
match tag {
None => {
if !closing {
out.push_str("<br>");
}
}
Some(t) if closing => {
if let Some(at) = self.open.iter().rposition(|o| o.emph == Emph::Tag(t)) {
self.after_close = None;
self.close_at(at, out, v);
}
}
Some(t) => self.open.push(Open {
emph: Emph::Tag(t),
at: out.len(),
run: 1,
ch: '<',
guess: false,
after_space: false,
before: self.prev_char(line, i),
split: false,
}),
}
self.prev = Some('>');
return Some(close + 1);
}
}
if !(self.in_cell && v.html_emphasis() && !closing && eq_ignore_case(name, "br")) {
self.repairs.stripped_html += 1;
}
if !closing && eq_ignore_case(name, "br") {
if v.keeps_br() {
out.push_str("<br>");
self.prev = Some(' ');
} else if self.in_cell {
if v.html_emphasis() {
out.push_str("<br>");
} else {
out.push(' ');
}
self.prev = Some(' ');
} else {
out.push('\n');
self.prev = Some('\n');
}
}
Some(close + 1)
}
fn render_link(&mut self, text: &[char], url: &[char], out: &mut String, v: &Vocab) {
self.render_target(text, url, out, v, false);
}
fn render_image(&mut self, alt: &[char], url: &[char], out: &mut String, v: &Vocab) {
self.render_target(alt, url, out, v, true);
}
fn render_target(&mut self, text: &[char], url: &[char], out: &mut String, v: &Vocab, image: bool) {
let mut scratch = std::mem::take(&mut self.scratch);
scratch.clear();
let mut nested = Inline::new();
nested.render(text, &mut scratch, v);
nested.finish_block(&mut scratch, v);
self.repairs.add(nested.repairs);
let mut href = String::with_capacity(url.len());
href.extend(url.iter());
if image {
v.image(&scratch, &href, out);
} else {
v.link(&scratch, &href, out);
}
self.scratch = scratch;
}
}
fn gfm_pairs(before: Option<char>, body: &str, after: Option<char>) -> bool {
let (left, right) = gfm_sides(before, body, after);
left && right
}
fn gfm_sides(before: Option<char>, body: &str, after: Option<char>) -> (bool, bool) {
let punct = |c: char| !c.is_alphanumeric() && !c.is_whitespace();
let (Some(first), Some(last)) = (body.chars().next(), body.chars().next_back()) else {
return (true, true);
};
let left = before != Some('>')
&& !first.is_whitespace()
&& (!punct(first) || before.is_none_or(|b| b.is_whitespace() || punct(b)));
let right = after != Some('<')
&& !last.is_whitespace()
&& (!punct(last) || after.is_none_or(|a| a.is_whitespace() || punct(a)));
(left, right)
}
const WORD_JOINER: char = '\u{2060}';
fn wrap_per_line(out: &mut String, at: usize, marker: (&str, &str), code: bool, buf: &mut String) {
buf.clear();
buf.push_str(&out[at..]);
out.truncate(at);
for (i, line) in buf.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
let lead = if i == 0 { 0 } else { line.len() - line.trim_start_matches([' ', '\t', '>']).len() };
let (prefix, rest) = line.split_at(lead);
let text = rest.trim_end_matches([' ', '\t']);
out.push_str(prefix);
if text.is_empty() {
out.push_str(rest);
continue;
}
if code {
let fence = longest_run(text, '`') + 1;
let pad = text.starts_with('`') || text.ends_with('`');
out.extend(std::iter::repeat_n('`', fence));
if pad {
out.push(' ');
}
out.push_str(text);
if pad {
out.push(' ');
}
out.extend(std::iter::repeat_n('`', fence));
} else {
out.push_str(marker.0);
out.push_str(text);
out.push_str(marker.1);
}
out.push_str(&rest[text.len()..]);
}
}
fn insert_marker(out: &mut String, at: usize, c: char, run: usize, v: &Vocab, repairs: &mut Repairs) {
let escaped = v.escapes(c);
if escaped {
repairs.escaped_char += run;
}
for _ in 0..run {
out.insert(at, c);
if escaped {
out.insert(at, '\\');
}
}
}
fn find_link(line: &[char], at: usize) -> Option<((usize, usize), usize, usize)> {
let mut j = at + 1;
let mut depth = 0usize;
while j < line.len() {
match line[j] {
'[' => depth += 1,
']' if depth == 0 => break,
']' => depth -= 1,
_ => {}
}
j += 1;
}
if j >= line.len() || line.get(j + 1) != Some(&'(') {
return None;
}
let k = line[j + 2..].iter().position(|&c| c == ')')? + j + 2;
Some(((at + 1, j), j + 2, k))
}
fn starts_with(chars: &[char], s: &str) -> bool {
let mut it = chars.iter();
s.chars().all(|c| it.next() == Some(&c))
}
fn find_seq(chars: &[char], s: &str) -> Option<usize> {
(0..chars.len()).find(|&k| starts_with(&chars[k..], s))
}
fn eq_ignore_case(chars: &[char], s: &str) -> bool {
chars.len() == s.len() && chars.iter().zip(s.chars()).all(|(a, b)| a.eq_ignore_ascii_case(&b))
}
fn is_known_tag(name: &[char]) -> bool {
const KNOWN: [&str; 22] = [
"br", "sub", "sup", "b", "strong", "i", "em", "u", "s", "strike", "del", "code", "span",
"div", "p", "small", "mark", "kbd", "font", "center", "details", "ins",
];
KNOWN.iter().any(|t| eq_ignore_case(name, t)) || eq_ignore_case(name, "summary")
}
fn renders_empty(rest: &[char], v: &Vocab) -> bool {
let mut i = 0;
while i < rest.len() {
match rest[i] {
' ' | '\t' => i += 1,
'<' if starts_with(&rest[i..], "<!--") => match find_seq(&rest[i + 4..], "-->") {
Some(end) => i += 4 + end + 3,
None => return false,
},
'<' => {
let closing = rest.get(i + 1) == Some(&'/');
let at = i + 1 + usize::from(closing);
let mut j = at;
while j < rest.len() && rest[j].is_ascii_alphanumeric() {
j += 1;
}
let name = &rest[at..j];
let kept = inline_tag(name).is_some() || eq_ignore_case(name, "br");
if name.is_empty() || !is_known_tag(name) || (kept && v.html_emphasis()) {
return false;
}
match rest[j..].iter().position(|&c| c == '>') {
Some(end) => i = j + end + 1,
None => return false,
}
}
_ => return false,
}
}
true
}
fn inline_tag(name: &[char]) -> Option<u8> {
INLINE_TAGS.iter().position(|(t, _, _)| eq_ignore_case(name, t)).map(|p| p as u8)
}
fn longest_run(s: &str, c: char) -> usize {
let mut best = 0;
let mut cur = 0;
for x in s.chars() {
if x == c {
cur += 1;
best = best.max(cur);
} else {
cur = 0;
}
}
best
}
fn run_len(line: &[char], at: usize, c: char) -> usize {
line[at..].iter().take_while(|&&x| x == c).count()
}
fn can_open(prev: Option<char>, next: Option<char>) -> bool {
next.is_some_and(|n| {
!n.is_whitespace() && (!is_punct(n) || prev.is_none_or(|p| !is_word_char(p)))
})
}
fn is_word_char(c: char) -> bool {
c.is_alphabetic() || c.is_ascii_digit()
}
fn is_punct(c: char) -> bool {
c.is_ascii_punctuation()
|| matches!(c,
',' | '。' | '、' | '!' | '?' | ';' | ':' | '·' | '…' | '—' | '~'
| '「' | '」' | '『' | '』' | '(' | ')' | '【' | '】' | '《' | '》'
| '“' | '”' | '‘' | '’')
}