1use regex::Regex;
2use std::sync::LazyLock;
13
14use super::mkdocs_common::get_line_indent;
15use crate::config::MarkdownFlavor;
16use crate::utils::skip_context::ByteRange;
17
18static AUTODOC_MARKER: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(
24 r"^(\s*):::\s+\S+.*$", )
26 .unwrap()
27});
28
29const OPTIONS_BLOCK_KEYS: [&str; 2] = ["handler:", "options:"];
32
33fn marker_identifier(line: &str) -> Option<&str> {
36 if !AUTODOC_MARKER.is_match(line) {
37 return None;
38 }
39 let identifier = line.trim_start().strip_prefix(":::")?.trim();
40 (!identifier.starts_with('{')).then_some(identifier)
41}
42
43pub fn is_autodoc_marker(line: &str) -> bool {
52 let Some(identifier) = marker_identifier(line) else {
53 return false;
54 };
55 let module_path = identifier.split_whitespace().next().unwrap_or_default();
57
58 if !module_path.contains(['.', ':']) {
62 return false;
63 }
64
65 if module_path.starts_with(['.', ':']) || module_path.ends_with(['.', ':']) {
68 return false;
69 }
70 !["..", "::", ".:", ":."].iter().any(|pair| module_path.contains(pair))
71}
72
73fn opens_autodoc_block(lines: &[&str], idx: usize, flavor: MarkdownFlavor) -> bool {
83 let line = lines[idx];
84 if is_autodoc_marker(line) {
85 return true;
86 }
87 if marker_identifier(line).is_none() || flavor.is_pandoc_compatible() {
88 return false;
89 }
90 flavor == MarkdownFlavor::MkDocs || starts_options_block(&lines[idx + 1..], get_line_indent(line))
91}
92
93fn starts_options_block(following: &[&str], marker_indent: usize) -> bool {
97 let mut rest = following.iter();
98 let first = match rest.next() {
99 Some(line) if line.trim().is_empty() => rest.next(),
100 other => other,
101 };
102 first.is_some_and(|line| {
103 let key = line.trim_start();
104 get_line_indent(line) >= marker_indent + 4 && OPTIONS_BLOCK_KEYS.iter().any(|k| key.starts_with(k))
105 })
106}
107
108pub fn is_autodoc_options(line: &str, base_indent: usize) -> bool {
110 let line_indent = get_line_indent(line);
112
113 if line_indent >= base_indent + 4 {
115 if line.trim().is_empty() {
117 return true;
118 }
119
120 if line.contains(':') {
122 return true;
123 }
124 let trimmed = line.trim_start();
126 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
127 return true;
128 }
129 }
130
131 false
132}
133
134pub fn detect_autodoc_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<ByteRange> {
137 let mut ranges = Vec::new();
138 let lines: Vec<&str> = content.lines().collect();
139 let mut byte_pos = 0;
140 let mut open_block: Option<(usize, usize)> = None;
142
143 for (idx, line) in lines.iter().enumerate() {
144 let line_start = byte_pos;
145 byte_pos += line.len() + 1;
147
148 if opens_autodoc_block(&lines, idx, flavor) {
149 if let Some((start, _)) = open_block {
151 ranges.push(ByteRange {
152 start,
153 end: line_start.saturating_sub(1),
154 });
155 }
156 open_block = Some((line_start, get_line_indent(line)));
157 } else if let Some((start, marker_indent)) = open_block {
158 if !is_autodoc_options(line, marker_indent) && !line.trim().is_empty() {
161 ranges.push(ByteRange {
162 start,
163 end: line_start.saturating_sub(1),
164 });
165 open_block = None;
166 }
167 }
168 }
169
170 if let Some((start, _)) = open_block {
172 ranges.push(ByteRange {
173 start,
174 end: byte_pos.saturating_sub(1),
175 });
176 }
177
178 ranges
179}
180
181pub fn is_within_autodoc_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
183 crate::utils::skip_context::is_in_html_comment_ranges(ranges, position)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn test_autodoc_marker_detection() {
192 assert!(is_autodoc_marker("::: mymodule.MyClass"));
194 assert!(is_autodoc_marker("::: package.module.Class"));
195 assert!(is_autodoc_marker(" ::: indented.Class"));
196 assert!(is_autodoc_marker("::: module:function"));
197 assert!(is_autodoc_marker("::: handler:package.module"));
198 assert!(is_autodoc_marker("::: a.b"));
199
200 assert!(!is_autodoc_marker(":: Wrong number"));
202 assert!(!is_autodoc_marker("Regular text"));
203 assert!(!is_autodoc_marker(":::"));
204 assert!(!is_autodoc_marker("::: "));
205
206 assert!(!is_autodoc_marker("::: warning"));
208 assert!(!is_autodoc_marker("::: note"));
209 assert!(!is_autodoc_marker("::: danger"));
210 assert!(!is_autodoc_marker("::: sidebar"));
211 assert!(!is_autodoc_marker(" ::: callout"));
212
213 assert!(!is_autodoc_marker("::: {.note}"));
215 assert!(!is_autodoc_marker("::: {#myid .warning}"));
216 assert!(!is_autodoc_marker("::: {.note .important}"));
217
218 assert!(!is_autodoc_marker("::: .starts.with.dot"));
220 assert!(!is_autodoc_marker("::: ends.with.dot."));
221 assert!(!is_autodoc_marker("::: has..consecutive.dots"));
222 assert!(!is_autodoc_marker("::: :starts.with.colon"));
223 }
224
225 fn autodoc_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
227 let ranges = detect_autodoc_block_ranges(content, flavor);
228 let mut offset = 0;
229 let mut lines = Vec::new();
230 for (idx, line) in content.lines().enumerate() {
231 if is_within_autodoc_block_ranges(&ranges, offset) {
232 lines.push(idx + 1);
233 }
234 offset += line.len() + 1;
235 }
236 lines
237 }
238
239 #[test]
240 fn test_single_word_marker_with_options_block() {
241 let content = "::: mypackage\n options:\n show_source: false\nText\n";
242 for flavor in [
243 MarkdownFlavor::Standard,
244 MarkdownFlavor::MkDocs,
245 MarkdownFlavor::Obsidian,
246 ] {
247 assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
248 }
249 for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
251 assert!(autodoc_lines(content, flavor).is_empty(), "{flavor:?}");
252 }
253 }
254
255 #[test]
256 fn test_options_block_after_one_blank_line() {
257 let content = "::: mypackage\n\n handler: python\nText\n";
258 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
259 let content = "::: mypackage\n\n\n handler: python\n";
261 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
262 }
263
264 #[test]
265 fn test_whitespace_only_blank_line_keeps_block_open() {
266 let content = "::: mypackage\n \n handler: python\nText\n";
268 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
269 let content = "::: pkg.mod\n \n options:\n x: 1\nText\n";
270 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3, 4]);
271 }
272
273 #[test]
274 fn test_pandoc_flavors_recognize_only_path_markers() {
275 let content = "::: module.Class\n options:\n x: 1\nText\n";
277 for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
278 assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
279 }
280 }
281
282 #[test]
283 fn test_single_word_marker_needs_options_key_outside_mkdocs() {
284 assert!(autodoc_lines("::: warning\nText\n", MarkdownFlavor::Standard).is_empty());
286 let content = "::: sidebar\n Important: note\n";
288 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
289 let content = " ::: mypackage\n options:\n";
291 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
292 let content = "::: {.note}\n options:\n";
294 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
295 assert!(autodoc_lines(content, MarkdownFlavor::MkDocs).is_empty());
296 }
297
298 #[test]
299 fn test_mkdocs_flavor_accepts_any_identifier() {
300 assert_eq!(autodoc_lines("::: mypackage\nText\n", MarkdownFlavor::MkDocs), vec![1]);
301 let content = "::: handler: python\n options:\n show_source: false\n";
302 assert_eq!(autodoc_lines(content, MarkdownFlavor::MkDocs), vec![1, 2, 3]);
303 }
304
305 #[test]
306 fn test_consecutive_blocks_each_keep_their_range() {
307 let content = "::: pkg.a\n options:\n x: 1\n\n::: pkg.b\n options:\n x: 1\n::: pkg.c\nText\n";
308 let ranges = detect_autodoc_block_ranges(content, MarkdownFlavor::Standard);
309 assert_eq!(ranges.len(), 3, "one range per block: {ranges:?}");
310 assert_eq!(
312 autodoc_lines(content, MarkdownFlavor::Standard),
313 vec![1, 2, 3, 5, 6, 7, 8]
314 );
315 }
316
317 #[test]
318 fn test_autodoc_options() {
319 assert!(is_autodoc_options(" handler: python", 0));
320 assert!(is_autodoc_options(" options:", 0));
321 assert!(is_autodoc_options(" show_source: true", 0));
322 assert!(!is_autodoc_options("", 0)); assert!(!is_autodoc_options("Not indented", 0));
324 assert!(!is_autodoc_options(" Only 2 spaces", 0));
325 assert!(is_autodoc_options(" - window", 0));
327 assert!(is_autodoc_options(" - app", 0));
328 }
329}