use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InertRegions {
ranges: Vec<Range<usize>>,
unterminated_comment: Option<usize>,
}
pub fn mask_inert(markdown: &str) -> String {
InertRegions::scan(markdown).mask(markdown)
}
pub fn inert_lines(markdown: &str) -> Vec<bool> {
InertRegions::scan(markdown).inert_lines(markdown)
}
impl InertRegions {
pub fn scan(markdown: &str) -> Self {
let bytes = markdown.as_bytes();
let mut ranges: Vec<Range<usize>> = Vec::new();
let mut fence: Option<(u8, usize)> = None;
let mut in_comment = false;
let mut comment_start: Option<usize> = None;
let mut in_indented_code = false;
let mut prev_blank = true;
let mut in_list = false;
let mut line_start = 0usize;
while line_start < bytes.len() {
let (content_end, next_start) = line_bounds(bytes, line_start);
let indent = indent_width(bytes, line_start, content_end);
let text_start = first_non_ws(bytes, line_start, content_end);
let is_blank = text_start == content_end;
if in_comment {
match find_bytes(bytes, line_start, content_end, b"-->") {
Some(at) => {
in_comment = false;
comment_start = None;
push_range(&mut ranges, line_start..at + 3);
scan_inline(
bytes,
at + 3,
content_end,
next_start,
&mut ranges,
&mut in_comment,
&mut comment_start,
);
}
None => push_range(&mut ranges, line_start..next_start),
}
prev_blank = is_blank;
line_start = next_start;
continue;
}
if let Some((fence_char, open_len)) = fence {
push_range(&mut ranges, line_start..next_start);
if closes_fence(bytes, text_start, content_end, fence_char, open_len) {
fence = None;
}
prev_blank = is_blank;
line_start = next_start;
continue;
}
if in_indented_code && (is_blank || indent >= 4) {
push_range(&mut ranges, line_start..next_start);
prev_blank = is_blank;
line_start = next_start;
continue;
}
in_indented_code = false;
if is_blank {
prev_blank = true;
line_start = next_start;
continue;
}
if indent >= 4 && prev_blank && !in_list {
in_indented_code = true;
push_range(&mut ranges, line_start..next_start);
prev_blank = false;
line_start = next_start;
continue;
}
if let Some((fence_char, run)) = opens_fence(bytes, text_start, content_end) {
fence = Some((fence_char, run));
push_range(&mut ranges, line_start..next_start);
prev_blank = false;
line_start = next_start;
continue;
}
if is_list_marker(bytes, text_start, content_end) {
in_list = true;
} else if indent == 0 && prev_blank {
in_list = false;
}
scan_inline(
bytes,
text_start,
content_end,
next_start,
&mut ranges,
&mut in_comment,
&mut comment_start,
);
prev_blank = false;
line_start = next_start;
}
Self {
ranges,
unterminated_comment: if in_comment { comment_start } else { None },
}
}
pub fn unterminated_comment(&self) -> Option<usize> {
self.unterminated_comment
}
pub fn ranges(&self) -> &[Range<usize>] {
&self.ranges
}
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
pub fn is_inert(&self, offset: usize) -> bool {
let idx = self.ranges.partition_point(|r| r.start <= offset);
match idx.checked_sub(1).and_then(|i| self.ranges.get(i)) {
Some(r) => offset < r.end,
None => false,
}
}
pub fn intersects(&self, range: Range<usize>) -> bool {
if range.start >= range.end {
return self.is_inert(range.start);
}
let idx = self.ranges.partition_point(|r| r.start < range.end);
self.ranges
.get(..idx)
.unwrap_or_default()
.iter()
.rev()
.take_while(|r| r.end > range.start)
.any(|r| r.start < range.end)
}
pub fn mask(&self, markdown: &str) -> String {
let mut out = markdown.as_bytes().to_vec();
for range in &self.ranges {
let end = range.end.min(out.len());
if let Some(slice) = out.get_mut(range.start..end) {
for b in slice {
if *b != b'\n' && *b != b'\r' {
*b = b' ';
}
}
}
}
String::from_utf8(out).unwrap_or_else(|_| markdown.to_string())
}
pub fn inert_lines(&self, markdown: &str) -> Vec<bool> {
let bytes = markdown.as_bytes();
let mut flags = Vec::new();
let mut line_start = 0usize;
while line_start < bytes.len() {
let (content_end, next_start) = line_bounds(bytes, line_start);
let text_start = first_non_ws(bytes, line_start, content_end);
let probe = if text_start == content_end {
line_start
} else {
text_start
};
flags.push(self.is_inert(probe));
line_start = next_start;
}
flags
}
}
fn line_bounds(bytes: &[u8], line_start: usize) -> (usize, usize) {
let mut i = line_start;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
let next_start = if i < bytes.len() { i + 1 } else { bytes.len() };
let mut content_end = i;
if content_end > line_start && bytes.get(content_end - 1) == Some(&b'\r') {
content_end -= 1;
}
(content_end, next_start)
}
fn first_non_ws(bytes: &[u8], from: usize, to: usize) -> usize {
let mut i = from;
while i < to && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
i
}
fn indent_width(bytes: &[u8], from: usize, to: usize) -> usize {
let mut width = 0usize;
let mut i = from;
while i < to {
match bytes[i] {
b' ' => width += 1,
b'\t' => width += 4 - (width % 4),
_ => break,
}
i += 1;
}
width
}
fn opens_fence(bytes: &[u8], text_start: usize, content_end: usize) -> Option<(u8, usize)> {
let ch = *bytes.get(text_start)?;
if ch != b'`' && ch != b'~' {
return None;
}
let mut run = 0usize;
while bytes.get(text_start + run) == Some(&ch) {
run += 1;
}
if run < 3 {
return None;
}
if ch == b'`' {
let info = bytes.get(text_start + run..content_end).unwrap_or_default();
if info.contains(&b'`') {
return None;
}
}
Some((ch, run))
}
fn closes_fence(
bytes: &[u8],
text_start: usize,
content_end: usize,
fence_char: u8,
open_len: usize,
) -> bool {
let mut run = 0usize;
while text_start + run < content_end && bytes.get(text_start + run) == Some(&fence_char) {
run += 1;
}
if run < open_len {
return false;
}
bytes
.get(text_start + run..content_end)
.unwrap_or_default()
.iter()
.all(|b| *b == b' ' || *b == b'\t')
}
fn is_list_marker(bytes: &[u8], text_start: usize, content_end: usize) -> bool {
let after_marker = match bytes.get(text_start) {
Some(b'-') | Some(b'*') | Some(b'+') => text_start + 1,
Some(d) if d.is_ascii_digit() => {
let mut i = text_start;
while i < content_end && bytes.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
match bytes.get(i) {
Some(b'.') | Some(b')') => i + 1,
_ => return false,
}
}
_ => return false,
};
match bytes.get(after_marker) {
None => true,
Some(b' ') | Some(b'\t') | Some(b'\r') | Some(b'\n') => true,
Some(_) => after_marker >= content_end,
}
}
#[allow(clippy::too_many_arguments)]
fn scan_inline(
bytes: &[u8],
from: usize,
content_end: usize,
next_start: usize,
ranges: &mut Vec<Range<usize>>,
in_comment: &mut bool,
comment_start: &mut Option<usize>,
) {
let mut i = from;
while i < content_end {
match bytes[i] {
b'`' => {
let mut run = 0usize;
while i + run < content_end && bytes[i + run] == b'`' {
run += 1;
}
match closing_backtick_run(bytes, i + run, content_end, run) {
Some(close) => {
push_range(ranges, i..close + run);
i = close + run;
}
None => i += run,
}
}
b'<' if bytes.get(i..i + 4) == Some(b"<!--".as_slice()) => {
match find_bytes(bytes, i + 2, content_end, b"-->") {
Some(at) => {
push_range(ranges, i..at + 3);
i = at + 3;
}
None => {
push_range(ranges, i..next_start);
*in_comment = true;
*comment_start = Some(i);
return;
}
}
}
_ => i += 1,
}
}
}
fn closing_backtick_run(bytes: &[u8], from: usize, to: usize, run: usize) -> Option<usize> {
let mut i = from;
while i < to {
if bytes[i] != b'`' {
i += 1;
continue;
}
let mut len = 0usize;
while i + len < to && bytes[i + len] == b'`' {
len += 1;
}
if len == run {
return Some(i);
}
i += len;
}
None
}
fn find_bytes(bytes: &[u8], from: usize, to: usize, needle: &[u8]) -> Option<usize> {
if needle.is_empty() || to < from {
return None;
}
let hay = bytes.get(from..to)?;
hay.windows(needle.len())
.position(|w| w == needle)
.map(|p| from + p)
}
fn push_range(ranges: &mut Vec<Range<usize>>, range: Range<usize>) {
if range.start >= range.end {
return;
}
if let Some(last) = ranges.last_mut() {
if range.start <= last.end {
last.end = last.end.max(range.end);
return;
}
}
ranges.push(range);
}
#[cfg(test)]
#[path = "inert_regions_tests.rs"]
mod tests;