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 !line.trim_start().starts_with(":::") || !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 is_autodoc_identifier(identifier)
56}
57
58fn is_autodoc_identifier(identifier: &str) -> bool {
59 let module_path = identifier.split_whitespace().next().unwrap_or_default();
61
62 if !module_path.contains(['.', ':']) {
66 return false;
67 }
68
69 if module_path.starts_with(['.', ':']) || module_path.ends_with(['.', ':']) {
72 return false;
73 }
74 !["..", "::", ".:", ":."].iter().any(|pair| module_path.contains(pair))
75}
76
77fn opens_autodoc_block(lines: &[&str], idx: usize, flavor: MarkdownFlavor) -> bool {
87 let line = lines[idx];
88 let Some(identifier) = marker_identifier(line) else {
89 return false;
90 };
91 if is_autodoc_identifier(identifier) {
92 return true;
93 }
94 if flavor.is_pandoc_compatible() {
95 return false;
96 }
97 flavor == MarkdownFlavor::MkDocs || starts_options_block(&lines[idx + 1..], get_line_indent(line))
98}
99
100fn starts_options_block(following: &[&str], marker_indent: usize) -> bool {
104 let mut rest = following.iter();
105 let first = match rest.next() {
106 Some(line) if line.trim().is_empty() => rest.next(),
107 other => other,
108 };
109 first.is_some_and(|line| {
110 let key = line.trim_start();
111 get_line_indent(line) >= marker_indent + 4 && OPTIONS_BLOCK_KEYS.iter().any(|k| key.starts_with(k))
112 })
113}
114
115pub fn is_autodoc_options(line: &str, base_indent: usize) -> bool {
117 let line_indent = get_line_indent(line);
119
120 if line_indent >= base_indent + 4 {
122 if line.trim().is_empty() {
124 return true;
125 }
126
127 if line.contains(':') {
129 return true;
130 }
131 let trimmed = line.trim_start();
133 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
134 return true;
135 }
136 }
137
138 false
139}
140
141pub fn detect_autodoc_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<ByteRange> {
144 if !content.contains(":::") {
145 return Vec::new();
146 }
147 autodoc_block_ranges_in_lines(&content.lines().collect::<Vec<_>>(), flavor)
148}
149
150pub(crate) fn autodoc_block_ranges_in_lines(lines: &[&str], flavor: MarkdownFlavor) -> Vec<ByteRange> {
152 let mut ranges = Vec::new();
153 let mut byte_pos = 0;
154 let mut open_block: Option<(usize, usize)> = None;
156
157 for (idx, line) in lines.iter().enumerate() {
158 let line_start = byte_pos;
159 byte_pos += line.len() + 1;
161
162 if opens_autodoc_block(lines, idx, flavor) {
163 if let Some((start, _)) = open_block {
165 ranges.push(ByteRange {
166 start,
167 end: line_start.saturating_sub(1),
168 });
169 }
170 open_block = Some((line_start, get_line_indent(line)));
171 } else if let Some((start, marker_indent)) = open_block {
172 if !is_autodoc_options(line, marker_indent) && !line.trim().is_empty() {
175 ranges.push(ByteRange {
176 start,
177 end: line_start.saturating_sub(1),
178 });
179 open_block = None;
180 }
181 }
182 }
183
184 if let Some((start, _)) = open_block {
186 ranges.push(ByteRange {
187 start,
188 end: byte_pos.saturating_sub(1),
189 });
190 }
191
192 ranges
193}
194
195pub fn is_within_autodoc_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
197 crate::utils::skip_context::is_in_html_comment_ranges(ranges, position)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_autodoc_marker_detection() {
206 assert!(is_autodoc_marker("::: mymodule.MyClass"));
208 assert!(is_autodoc_marker("::: package.module.Class"));
209 assert!(is_autodoc_marker(" ::: indented.Class"));
210 assert!(is_autodoc_marker("::: module:function"));
211 assert!(is_autodoc_marker("::: handler:package.module"));
212 assert!(is_autodoc_marker("::: a.b"));
213
214 assert!(!is_autodoc_marker(":: Wrong number"));
216 assert!(!is_autodoc_marker("Regular text"));
217 assert!(!is_autodoc_marker(":::"));
218 assert!(!is_autodoc_marker("::: "));
219
220 assert!(!is_autodoc_marker("::: warning"));
222 assert!(!is_autodoc_marker("::: note"));
223 assert!(!is_autodoc_marker("::: danger"));
224 assert!(!is_autodoc_marker("::: sidebar"));
225 assert!(!is_autodoc_marker(" ::: callout"));
226
227 assert!(!is_autodoc_marker("::: {.note}"));
229 assert!(!is_autodoc_marker("::: {#myid .warning}"));
230 assert!(!is_autodoc_marker("::: {.note .important}"));
231
232 assert!(!is_autodoc_marker("::: .starts.with.dot"));
234 assert!(!is_autodoc_marker("::: ends.with.dot."));
235 assert!(!is_autodoc_marker("::: has..consecutive.dots"));
236 assert!(!is_autodoc_marker("::: :starts.with.colon"));
237 }
238
239 fn autodoc_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
241 let ranges = detect_autodoc_block_ranges(content, flavor);
242 let mut offset = 0;
243 let mut lines = Vec::new();
244 for (idx, line) in content.lines().enumerate() {
245 if is_within_autodoc_block_ranges(&ranges, offset) {
246 lines.push(idx + 1);
247 }
248 offset += line.len() + 1;
249 }
250 lines
251 }
252
253 #[test]
254 fn test_single_word_marker_with_options_block() {
255 let content = "::: mypackage\n options:\n show_source: false\nText\n";
256 for flavor in [
257 MarkdownFlavor::Standard,
258 MarkdownFlavor::MkDocs,
259 MarkdownFlavor::Obsidian,
260 ] {
261 assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
262 }
263 for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
265 assert!(autodoc_lines(content, flavor).is_empty(), "{flavor:?}");
266 }
267 }
268
269 #[test]
270 fn test_options_block_after_one_blank_line() {
271 let content = "::: mypackage\n\n handler: python\nText\n";
272 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
273 let content = "::: mypackage\n\n\n handler: python\n";
275 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
276 }
277
278 #[test]
279 fn test_whitespace_only_blank_line_keeps_block_open() {
280 let content = "::: mypackage\n \n handler: python\nText\n";
282 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
283 let content = "::: pkg.mod\n \n options:\n x: 1\nText\n";
284 assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3, 4]);
285 }
286
287 #[test]
288 fn test_pandoc_flavors_recognize_only_path_markers() {
289 let content = "::: module.Class\n options:\n x: 1\nText\n";
291 for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
292 assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
293 }
294 }
295
296 #[test]
297 fn test_single_word_marker_needs_options_key_outside_mkdocs() {
298 assert!(autodoc_lines("::: warning\nText\n", MarkdownFlavor::Standard).is_empty());
300 let content = "::: sidebar\n Important: note\n";
302 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
303 let content = " ::: mypackage\n options:\n";
305 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
306 let content = "::: {.note}\n options:\n";
308 assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
309 assert!(autodoc_lines(content, MarkdownFlavor::MkDocs).is_empty());
310 }
311
312 #[test]
313 fn test_mkdocs_flavor_accepts_any_identifier() {
314 assert_eq!(autodoc_lines("::: mypackage\nText\n", MarkdownFlavor::MkDocs), vec![1]);
315 let content = "::: handler: python\n options:\n show_source: false\n";
316 assert_eq!(autodoc_lines(content, MarkdownFlavor::MkDocs), vec![1, 2, 3]);
317 }
318
319 #[test]
320 fn test_consecutive_blocks_each_keep_their_range() {
321 let content = "::: pkg.a\n options:\n x: 1\n\n::: pkg.b\n options:\n x: 1\n::: pkg.c\nText\n";
322 let ranges = detect_autodoc_block_ranges(content, MarkdownFlavor::Standard);
323 assert_eq!(ranges.len(), 3, "one range per block: {ranges:?}");
324 assert_eq!(
326 autodoc_lines(content, MarkdownFlavor::Standard),
327 vec![1, 2, 3, 5, 6, 7, 8]
328 );
329 }
330
331 #[test]
332 fn test_autodoc_options() {
333 assert!(is_autodoc_options(" handler: python", 0));
334 assert!(is_autodoc_options(" options:", 0));
335 assert!(is_autodoc_options(" show_source: true", 0));
336 assert!(!is_autodoc_options("", 0)); assert!(!is_autodoc_options("Not indented", 0));
338 assert!(!is_autodoc_options(" Only 2 spaces", 0));
339 assert!(is_autodoc_options(" - window", 0));
341 assert!(is_autodoc_options(" - app", 0));
342 }
343}