use regex::Regex;
pub struct Fence {
pub info: String,
pub first_line: usize,
pub body: String,
}
pub struct Scan {
pub fences: Vec<Fence>,
pub prose: Vec<(usize, String)>,
}
fn fence_marker(line: &str) -> Option<(char, usize, String)> {
let trimmed = line.trim_start();
let ch = trimmed.chars().next().filter(|c| *c == '`' || *c == '~')?;
let len = trimmed.chars().take_while(|c| *c == ch).count();
(len >= 3).then(|| (ch, len, trimmed[len..].trim().to_string()))
}
pub fn scan(text: &str) -> Scan {
let mut fences = Vec::new();
let mut prose = Vec::new();
let mut open: Option<(char, usize, Fence)> = None;
for (i, line) in text.lines().enumerate() {
let number = i + 1;
match (&mut open, fence_marker(line)) {
(None, Some((ch, len, info))) => {
let fence = Fence {
info,
first_line: number + 1,
body: String::new(),
};
open = Some((ch, len, fence));
}
(Some((ch, len, _)), Some((c, l, info)))
if c == *ch && l >= *len && info.is_empty() =>
{
if let Some((_, _, fence)) = open.take() {
fences.push(fence);
}
}
(Some((_, _, fence)), _) => {
fence.body.push_str(line);
fence.body.push('\n');
}
(None, None) => prose.push((number, line.to_string())),
}
}
if let Some((_, _, fence)) = open {
fences.push(fence);
}
Scan { fences, prose }
}
pub fn inline_code(line: &str) -> (Vec<String>, String) {
let chars: Vec<char> = line.chars().collect();
let mut spans = Vec::new();
let mut rest = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] != '`' {
rest.push(chars[i]);
i += 1;
continue;
}
let run = chars[i..].iter().take_while(|c| **c == '`').count();
let body_start = i + run;
let mut j = body_start;
let mut close = None;
while j < chars.len() {
if chars[j] == '`' {
let r = chars[j..].iter().take_while(|c| **c == '`').count();
if r == run {
close = Some(j);
break;
}
j += r;
} else {
j += 1;
}
}
if let Some(end) = close {
spans.push(chars[body_start..end].iter().collect::<String>());
i = end + run;
} else {
rest.extend(&chars[i..body_start]);
i = body_start;
}
}
(spans, rest)
}
pub fn heading(line: &str) -> Option<(usize, &str)> {
let hashes = line.chars().take_while(|c| *c == '#').count();
((1..=6).contains(&hashes) && line[hashes..].starts_with(' '))
.then(|| (hashes, line[hashes..].trim()))
}
pub fn github_slug(text: &str) -> String {
let link = Regex::new(r"\[([^\]]*)\]\([^)]*\)").expect("regex");
let tag = Regex::new(r"<[^>]*>").expect("regex");
let plain = link.replace_all(text, "$1");
let plain = tag.replace_all(&plain, "");
plain
.replace('`', "")
.trim()
.to_lowercase()
.chars()
.filter_map(|c| match c {
c if c.is_whitespace() => Some('-'),
c if c.is_alphanumeric() || c == '-' || c == '_' => Some(c),
_ => None,
})
.collect()
}
pub fn anchors(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (_, line) in scan(text).prose {
if let Some((_, title)) = heading(&line) {
let base = github_slug(title);
let mut slug = base.clone();
let mut n = 0;
while out.contains(&slug) {
n += 1;
slug = format!("{base}-{n}");
}
out.push(slug);
}
}
let attr = Regex::new(r#"(?:id|name)="([^"]+)""#).expect("regex");
out.extend(attr.captures_iter(text).map(|c| c[1].to_string()));
out
}
pub fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = |b: u8| (b as char).to_digit(16);
match (
bytes[i],
bytes.get(i + 1).copied().and_then(hex),
bytes.get(i + 2).copied().and_then(hex),
) {
(b'%', Some(h), Some(l)) => {
out.push((h * 16 + l) as u8);
i += 3;
}
(b, _, _) => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn relative_links(text: &str) -> Vec<(usize, String)> {
let inline = Regex::new(r#"\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)"#).expect("regex");
let reference = Regex::new(r"^\s*\[[^\]]+\]:\s*<?(\S+?)>?\s*$").expect("regex");
let mut links = Vec::new();
for (number, line) in scan(text).prose {
let (_, plain) = inline_code(&line);
let targets = inline
.captures_iter(&plain)
.map(|c| c[1].to_string())
.chain(reference.captures(&plain).map(|c| c[1].to_string()));
for target in targets {
let external = target.contains("://")
|| ["mailto:", "tel:", "data:"]
.iter()
.any(|p| target.starts_with(p));
if !external {
links.push((number, target));
}
}
}
links
}
#[derive(Debug, PartialEq)]
pub struct Tok {
pub text: String,
pub line: usize,
pub quoted: bool,
}
pub fn command_words(block: &str, start: usize, first_line: usize) -> Vec<Tok> {
let chars: Vec<(usize, char)> = block[start..]
.char_indices()
.map(|(i, c)| (start + i, c))
.collect();
let line_at = |byte: usize| first_line + block[..byte].matches('\n').count();
let mut toks = Vec::new();
let mut word = String::new();
let mut quoted = false;
let mut word_at = start;
let mut quote: Option<char> = None;
let mut depth = 0usize;
let mut i = 0;
let flush = |word: &mut String, quoted: &mut bool, at: usize, toks: &mut Vec<Tok>| {
if !word.is_empty() || *quoted {
toks.push(Tok {
text: std::mem::take(word),
line: line_at(at),
quoted: *quoted,
});
}
*quoted = false;
};
while i < chars.len() {
let (at, c) = chars[i];
let next = chars.get(i + 1).map(|(_, n)| *n);
if let Some(q) = quote {
if c == q {
quote = None;
} else if c == '\\' && q == '"' && next.is_some() {
i += 1;
word.push(chars[i].1);
} else {
word.push(c);
}
i += 1;
continue;
}
match c {
'\\' if matches!(next, Some('\n' | '\r')) => {
i += 1;
if next == Some('\r') && chars.get(i + 1).map(|(_, n)| *n) == Some('\n') {
i += 1;
}
flush(&mut word, &mut quoted, word_at, &mut toks);
}
'\\' if next.is_some() => {
i += 1;
word.push(chars[i].1);
}
'\'' | '"' => {
if word.is_empty() && !quoted {
word_at = at;
}
quote = Some(c);
quoted = true;
}
'$' if next == Some('(') => {
depth += 1;
word.push_str("$(");
i += 1;
}
')' if depth > 0 => {
depth -= 1;
word.push(')');
}
'#' if word.is_empty() && !quoted => {
while i < chars.len() && chars[i].1 != '\n' {
i += 1;
}
break;
}
' ' | '\t' => flush(&mut word, &mut quoted, word_at, &mut toks),
'\n' | '|' | '&' | ';' | '>' | '<' | ')' => break,
_ => {
if word.is_empty() && !quoted {
word_at = at;
}
word.push(c);
}
}
i += 1;
}
flush(&mut word, &mut quoted, word_at, &mut toks);
toks
}
const COMMAND_PREFIXES: &[&str] = &["sudo", "time", "then", "do", "else", "exec", "command"];
fn in_command_position(prefix: &str) -> bool {
let pre = prefix.trim_end();
if prefix.trim_start().starts_with('#') {
return false;
}
if pre.is_empty() || pre.ends_with(['|', '&', ';', '(', '`', '$', '!', '{', '>']) {
return true;
}
let last = pre.rsplit(char::is_whitespace).next().unwrap_or("");
COMMAND_PREFIXES.contains(&last) || (last.contains('=') && !last.starts_with('-'))
}
pub fn invocations(block: &str, first_line: usize) -> Vec<Vec<Tok>> {
let program = Regex::new(r"atomwrite(?:\.exe)?").expect("regex");
let mut found = Vec::new();
for m in program.find_iter(block) {
let before = block[..m.start()].chars().next_back();
let after = block[m.end()..].chars().next();
let glued_before = before.is_some_and(|c| {
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '\\' | '$')
});
let glued_after = after.is_some_and(|c| {
!(c.is_whitespace() || matches!(c, ')' | '"' | '\'' | ';' | '|' | '&'))
});
let line_start = block[..m.start()].rfind('\n').map_or(0, |p| p + 1);
if glued_before || glued_after || !in_command_position(&block[line_start..m.start()]) {
continue;
}
found.push(command_words(block, m.end(), first_line));
}
found
}
pub const SHELL_INFOS: &[&str] = &[
"",
"sh",
"bash",
"shell",
"console",
"zsh",
"fish",
"powershell",
"ps1",
"pwsh",
"text",
"txt",
"cmd",
"bat",
];
#[derive(Debug, PartialEq, Eq)]
pub struct Shape {
pub h1: usize,
pub h2: usize,
pub h3: usize,
pub fences: usize,
pub table_rows: usize,
}
pub fn shape(text: &str) -> Shape {
let scanned = scan(text);
let level = |n: usize| {
scanned
.prose
.iter()
.filter(|(_, l)| heading(l).is_some_and(|(lv, _)| lv == n))
.count()
};
Shape {
h1: level(1),
h2: level(2),
h3: level(3),
fences: scanned.fences.len(),
table_rows: scanned
.prose
.iter()
.filter(|(_, l)| l.trim_start().starts_with('|'))
.count(),
}
}