use regex::Regex;
use std::sync::LazyLock;
use super::mkdocs_common::get_line_indent;
use crate::config::MarkdownFlavor;
use crate::utils::skip_context::ByteRange;
static AUTODOC_MARKER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(\s*):::\s+\S+.*$", )
.unwrap()
});
const OPTIONS_BLOCK_KEYS: [&str; 2] = ["handler:", "options:"];
fn marker_identifier(line: &str) -> Option<&str> {
if !AUTODOC_MARKER.is_match(line) {
return None;
}
let identifier = line.trim_start().strip_prefix(":::")?.trim();
(!identifier.starts_with('{')).then_some(identifier)
}
pub fn is_autodoc_marker(line: &str) -> bool {
let Some(identifier) = marker_identifier(line) else {
return false;
};
let module_path = identifier.split_whitespace().next().unwrap_or_default();
if !module_path.contains(['.', ':']) {
return false;
}
if module_path.starts_with(['.', ':']) || module_path.ends_with(['.', ':']) {
return false;
}
!["..", "::", ".:", ":."].iter().any(|pair| module_path.contains(pair))
}
fn opens_autodoc_block(lines: &[&str], idx: usize, flavor: MarkdownFlavor) -> bool {
let line = lines[idx];
if is_autodoc_marker(line) {
return true;
}
if marker_identifier(line).is_none() || flavor.is_pandoc_compatible() {
return false;
}
flavor == MarkdownFlavor::MkDocs || starts_options_block(&lines[idx + 1..], get_line_indent(line))
}
fn starts_options_block(following: &[&str], marker_indent: usize) -> bool {
let mut rest = following.iter();
let first = match rest.next() {
Some(line) if line.trim().is_empty() => rest.next(),
other => other,
};
first.is_some_and(|line| {
let key = line.trim_start();
get_line_indent(line) >= marker_indent + 4 && OPTIONS_BLOCK_KEYS.iter().any(|k| key.starts_with(k))
})
}
pub fn is_autodoc_options(line: &str, base_indent: usize) -> bool {
let line_indent = get_line_indent(line);
if line_indent >= base_indent + 4 {
if line.trim().is_empty() {
return true;
}
if line.contains(':') {
return true;
}
let trimmed = line.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
return true;
}
}
false
}
pub fn detect_autodoc_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut byte_pos = 0;
let mut open_block: Option<(usize, usize)> = None;
for (idx, line) in lines.iter().enumerate() {
let line_start = byte_pos;
byte_pos += line.len() + 1;
if opens_autodoc_block(&lines, idx, flavor) {
if let Some((start, _)) = open_block {
ranges.push(ByteRange {
start,
end: line_start.saturating_sub(1),
});
}
open_block = Some((line_start, get_line_indent(line)));
} else if let Some((start, marker_indent)) = open_block {
if !is_autodoc_options(line, marker_indent) && !line.trim().is_empty() {
ranges.push(ByteRange {
start,
end: line_start.saturating_sub(1),
});
open_block = None;
}
}
}
if let Some((start, _)) = open_block {
ranges.push(ByteRange {
start,
end: byte_pos.saturating_sub(1),
});
}
ranges
}
pub fn is_within_autodoc_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
crate::utils::skip_context::is_in_html_comment_ranges(ranges, position)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_autodoc_marker_detection() {
assert!(is_autodoc_marker("::: mymodule.MyClass"));
assert!(is_autodoc_marker("::: package.module.Class"));
assert!(is_autodoc_marker(" ::: indented.Class"));
assert!(is_autodoc_marker("::: module:function"));
assert!(is_autodoc_marker("::: handler:package.module"));
assert!(is_autodoc_marker("::: a.b"));
assert!(!is_autodoc_marker(":: Wrong number"));
assert!(!is_autodoc_marker("Regular text"));
assert!(!is_autodoc_marker(":::"));
assert!(!is_autodoc_marker("::: "));
assert!(!is_autodoc_marker("::: warning"));
assert!(!is_autodoc_marker("::: note"));
assert!(!is_autodoc_marker("::: danger"));
assert!(!is_autodoc_marker("::: sidebar"));
assert!(!is_autodoc_marker(" ::: callout"));
assert!(!is_autodoc_marker("::: {.note}"));
assert!(!is_autodoc_marker("::: {#myid .warning}"));
assert!(!is_autodoc_marker("::: {.note .important}"));
assert!(!is_autodoc_marker("::: .starts.with.dot"));
assert!(!is_autodoc_marker("::: ends.with.dot."));
assert!(!is_autodoc_marker("::: has..consecutive.dots"));
assert!(!is_autodoc_marker("::: :starts.with.colon"));
}
fn autodoc_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
let ranges = detect_autodoc_block_ranges(content, flavor);
let mut offset = 0;
let mut lines = Vec::new();
for (idx, line) in content.lines().enumerate() {
if is_within_autodoc_block_ranges(&ranges, offset) {
lines.push(idx + 1);
}
offset += line.len() + 1;
}
lines
}
#[test]
fn test_single_word_marker_with_options_block() {
let content = "::: mypackage\n options:\n show_source: false\nText\n";
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::Obsidian,
] {
assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
}
for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
assert!(autodoc_lines(content, flavor).is_empty(), "{flavor:?}");
}
}
#[test]
fn test_options_block_after_one_blank_line() {
let content = "::: mypackage\n\n handler: python\nText\n";
assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
let content = "::: mypackage\n\n\n handler: python\n";
assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
}
#[test]
fn test_whitespace_only_blank_line_keeps_block_open() {
let content = "::: mypackage\n \n handler: python\nText\n";
assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
let content = "::: pkg.mod\n \n options:\n x: 1\nText\n";
assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3, 4]);
}
#[test]
fn test_pandoc_flavors_recognize_only_path_markers() {
let content = "::: module.Class\n options:\n x: 1\nText\n";
for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
}
}
#[test]
fn test_single_word_marker_needs_options_key_outside_mkdocs() {
assert!(autodoc_lines("::: warning\nText\n", MarkdownFlavor::Standard).is_empty());
let content = "::: sidebar\n Important: note\n";
assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
let content = " ::: mypackage\n options:\n";
assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
let content = "::: {.note}\n options:\n";
assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
assert!(autodoc_lines(content, MarkdownFlavor::MkDocs).is_empty());
}
#[test]
fn test_mkdocs_flavor_accepts_any_identifier() {
assert_eq!(autodoc_lines("::: mypackage\nText\n", MarkdownFlavor::MkDocs), vec![1]);
let content = "::: handler: python\n options:\n show_source: false\n";
assert_eq!(autodoc_lines(content, MarkdownFlavor::MkDocs), vec![1, 2, 3]);
}
#[test]
fn test_consecutive_blocks_each_keep_their_range() {
let content = "::: pkg.a\n options:\n x: 1\n\n::: pkg.b\n options:\n x: 1\n::: pkg.c\nText\n";
let ranges = detect_autodoc_block_ranges(content, MarkdownFlavor::Standard);
assert_eq!(ranges.len(), 3, "one range per block: {ranges:?}");
assert_eq!(
autodoc_lines(content, MarkdownFlavor::Standard),
vec![1, 2, 3, 5, 6, 7, 8]
);
}
#[test]
fn test_autodoc_options() {
assert!(is_autodoc_options(" handler: python", 0));
assert!(is_autodoc_options(" options:", 0));
assert!(is_autodoc_options(" show_source: true", 0));
assert!(!is_autodoc_options("", 0)); assert!(!is_autodoc_options("Not indented", 0));
assert!(!is_autodoc_options(" Only 2 spaces", 0));
assert!(is_autodoc_options(" - window", 0));
assert!(is_autodoc_options(" - app", 0));
}
}