use std::sync::LazyLock;
use regex::Regex;
pub(super) struct FencedBlock<'a> {
pub(super) start: usize,
pub(super) end: usize,
pub(super) language: Option<&'a str>,
pub(super) filename: Option<&'a str>,
pub(super) code: &'a str,
}
pub(super) fn find_fenced_blocks(content: &str) -> Vec<FencedBlock<'_>> {
struct OpenFence<'a> {
marker: char,
start: usize,
body_start: usize,
language: Option<&'a str>,
filename: Option<&'a str>,
}
let mut blocks = Vec::new();
let mut open: Option<OpenFence> = None;
let mut line_start = 0;
for line in content.split_inclusive('\n') {
let line_end = line_start + line.len();
let text = line.strip_suffix('\n').unwrap_or(line);
let text = text.strip_suffix('\r').unwrap_or(text);
match &open {
Some(fence) => {
if line_start > fence.body_start && is_closing_fence(text, fence.marker) {
blocks.push(FencedBlock {
start: fence.start,
end: line_end,
language: fence.language,
filename: fence.filename,
code: &content[fence.body_start..line_start],
});
open = None;
}
}
None => {
if let Some((marker, language, filename)) = parse_opening_fence(text) {
open = Some(OpenFence {
marker,
start: line_start,
body_start: line_end,
language,
filename,
});
}
}
}
line_start = line_end;
}
blocks
}
fn split_fence_marker(text: &str) -> Option<(char, &str)> {
let rest = text.trim_start_matches([' ', '\t']);
if let Some(info) = rest.strip_prefix("```") {
Some(('`', info))
} else if let Some(info) = rest.strip_prefix("~~~") {
Some(('~', info))
} else {
None
}
}
fn parse_opening_fence(text: &str) -> Option<(char, Option<&str>, Option<&str>)> {
let (marker, info) = split_fence_marker(text)?;
let lang_end = info
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(info.len());
let (language, rest) = info.split_at(lang_end);
if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
return None;
}
let filename = rest.trim_start_matches([' ', '\t']);
Some((
marker,
(!language.is_empty()).then_some(language),
(!filename.is_empty()).then_some(filename),
))
}
fn is_closing_fence(text: &str, marker: char) -> bool {
match split_fence_marker(text) {
Some((m, rest)) => m == marker && rest.chars().all(|c| c == ' ' || c == '\t'),
None => false,
}
}
pub(super) fn find_closing_tag(content: &str, tag_name: &str) -> Option<usize> {
let open_tag = format!("<{}", tag_name);
let close_tag = format!("</{}>", tag_name);
let mut depth = 1;
let mut pos = 0;
while depth > 0 && pos < content.len() {
let next_open = find_open_tag(content, pos, &open_tag);
let next_close = content[pos..].find(&close_tag).map(|i| i + pos);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
depth += 1;
pos = o + open_tag.len();
}
(_, Some(c)) => {
depth -= 1;
if depth == 0 {
return Some(c);
}
pos = c + close_tag.len();
}
_ => return None,
}
}
None
}
fn find_open_tag(content: &str, from: usize, open_tag: &str) -> Option<usize> {
let mut search = from;
while let Some(rel) = content[search..].find(open_tag) {
let idx = search + rel;
let after = idx + open_tag.len();
let ends_here = content[after..]
.chars()
.next()
.is_none_or(|ch| ch.is_whitespace() || ch == '>' || ch == '/');
if ends_here {
return Some(idx);
}
search = after;
}
None
}
pub(super) fn skip_to_next_tag<'a>(content: &'a str, tag: &str) -> Option<&'a str> {
let skip = content.chars().next()?.len_utf8();
content[skip..].find(tag).map(|idx| &content[skip + idx..])
}
pub(super) fn extract_attr(tag_content: &str, attr_name: &str) -> Option<String> {
static ATTR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"([A-Za-z_:][-A-Za-z0-9_:.]*)="([^"]*)""#).unwrap());
ATTR_RE
.captures_iter(tag_content)
.find(|caps| &caps[1] == attr_name)
.map(|caps| caps[2].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skip_to_next_tag_handles_multibyte_leading_char() {
assert_eq!(
skip_to_next_tag("Überblick<Tab title=\"A\">", "<Tab"),
Some("<Tab title=\"A\">")
);
}
#[test]
fn skip_to_next_tag_always_advances() {
assert_eq!(skip_to_next_tag("<Card a<Card b", "<Card"), Some("<Card b"));
assert_eq!(skip_to_next_tag("<Card only", "<Card"), None);
}
#[test]
fn skip_to_next_tag_handles_empty_input() {
assert_eq!(skip_to_next_tag("", "<Card"), None);
}
#[test]
fn extract_attr_matches_whole_attribute_names() {
let tag = r#" subtitle="Sub" title="Real""#;
assert_eq!(extract_attr(tag, "title").as_deref(), Some("Real"));
assert_eq!(extract_attr(tag, "subtitle").as_deref(), Some("Sub"));
}
#[test]
fn extract_attr_returns_none_for_absent_attribute() {
assert_eq!(extract_attr(r#" title="A""#, "icon"), None);
assert_eq!(extract_attr("", "title"), None);
}
#[test]
fn extract_attr_reads_each_attribute_of_a_tag() {
let tag = r#" title="A Card" icon="star" href="/link""#;
assert_eq!(extract_attr(tag, "title").as_deref(), Some("A Card"));
assert_eq!(extract_attr(tag, "icon").as_deref(), Some("star"));
assert_eq!(extract_attr(tag, "href").as_deref(), Some("/link"));
}
#[test]
fn find_closing_tag_ignores_longer_tag_names() {
let inner = "outer<Tabs><Tab>x</Tab></Tabs></Tab>rest";
let close = find_closing_tag(inner, "Tab").expect("closing </Tab>");
assert_eq!(&inner[close..], "</Tab>rest");
}
#[test]
fn find_closing_tag_still_counts_real_nesting() {
let inner = "<Tab>inner</Tab></Tab>rest";
let close = find_closing_tag(inner, "Tab").expect("closing </Tab>");
assert_eq!(&inner[close..], "</Tab>rest");
}
}