Skip to main content

plissken_core/
docstring.rs

1//! Docstring parser for Google, NumPy, and Rust doc comment styles
2//!
3//! This module parses docstrings into structured `ParsedDocstring` objects,
4//! extracting summary, parameters, returns, raises, and examples.
5//!
6//! Supported formats:
7//! - **Google style**: `Args:`, `Returns:`, `Raises:`, `Example:`
8//! - **NumPy style**: Underlined section headers
9//! - **Rust style**: `# Arguments`, `# Returns`, `# Errors`, `# Panics`, `# Examples`
10
11use crate::model::{ParamDoc, ParsedDocstring, RaisesDoc, ReturnDoc};
12
13/// Parse a docstring into structured form
14pub fn parse_docstring(docstring: &str) -> ParsedDocstring {
15    let docstring = docstring.trim();
16    if docstring.is_empty() {
17        return ParsedDocstring::empty();
18    }
19
20    // Detect style based on section format
21    let style = detect_style(docstring);
22
23    match style {
24        DocstringStyle::Google => parse_google_style(docstring),
25        DocstringStyle::NumPy => parse_numpy_style(docstring),
26        DocstringStyle::Plain => parse_plain(docstring),
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
31enum DocstringStyle {
32    Google,
33    NumPy,
34    Plain,
35}
36
37/// Detect the docstring style based on section markers
38fn detect_style(docstring: &str) -> DocstringStyle {
39    // NumPy style uses underlined section headers like:
40    // Parameters
41    // ----------
42    if docstring.contains("\n----------")
43        || docstring.contains("\n---------")
44        || docstring.contains("\n--------")
45    {
46        return DocstringStyle::NumPy;
47    }
48
49    // Google style uses "Section:" format
50    let google_markers = [
51        "Args:",
52        "Arguments:",
53        "Parameters:",
54        "Returns:",
55        "Raises:",
56        "Raises:",
57        "Example:",
58        "Examples:",
59        "Attributes:",
60        "Note:",
61        "Notes:",
62        "Yields:",
63    ];
64
65    for marker in &google_markers {
66        if docstring.contains(marker) {
67            return DocstringStyle::Google;
68        }
69    }
70
71    DocstringStyle::Plain
72}
73
74/// Parse Google-style docstring
75fn parse_google_style(docstring: &str) -> ParsedDocstring {
76    let lines: Vec<&str> = docstring.lines().collect();
77
78    // Find summary - everything before first section or blank line
79    let (summary, description, section_start) = extract_summary_and_description(&lines);
80
81    let mut params = Vec::new();
82    let mut returns = None;
83    let mut raises = Vec::new();
84    let mut examples = Vec::new();
85
86    // Parse sections
87    let mut i = section_start;
88    while i < lines.len() {
89        let line = lines[i].trim();
90
91        if line.ends_with(':') && !line.contains(' ') {
92            // This might be a section header
93            let section_name = &line[..line.len() - 1];
94            match section_name.to_lowercase().as_str() {
95                "args" | "arguments" | "parameters" | "params" => {
96                    let (parsed, next_i) = parse_google_params(&lines, i + 1);
97                    params = parsed;
98                    i = next_i;
99                }
100                "returns" | "return" => {
101                    let (parsed, next_i) = parse_google_returns(&lines, i + 1);
102                    returns = parsed;
103                    i = next_i;
104                }
105                "raises" | "raise" | "exceptions" | "except" => {
106                    let (parsed, next_i) = parse_google_raises(&lines, i + 1);
107                    raises = parsed;
108                    i = next_i;
109                }
110                "example" | "examples" => {
111                    let (parsed, next_i) = parse_google_examples(&lines, i + 1);
112                    examples = parsed;
113                    i = next_i;
114                }
115                _ => {
116                    i += 1;
117                }
118            }
119        } else {
120            i += 1;
121        }
122    }
123
124    ParsedDocstring {
125        summary,
126        description,
127        params,
128        returns,
129        raises,
130        examples,
131    }
132}
133
134/// Parse NumPy-style docstring
135fn parse_numpy_style(docstring: &str) -> ParsedDocstring {
136    let lines: Vec<&str> = docstring.lines().collect();
137
138    // Find summary
139    let (summary, description, section_start) = extract_summary_and_description(&lines);
140
141    let mut params = Vec::new();
142    let mut returns = None;
143    let mut raises = Vec::new();
144    let mut examples = Vec::new();
145
146    // Parse sections - NumPy uses underlined headers
147    let mut i = section_start;
148    while i < lines.len() {
149        let line = lines[i].trim();
150
151        // Check if this is a section header (followed by dashes)
152        if i + 1 < lines.len() {
153            let next_line = lines[i + 1].trim();
154            if next_line.chars().all(|c| c == '-') && !next_line.is_empty() {
155                match line.to_lowercase().as_str() {
156                    "parameters" | "params" | "arguments" => {
157                        let (parsed, next_i) = parse_numpy_params(&lines, i + 2);
158                        params = parsed;
159                        i = next_i;
160                        continue;
161                    }
162                    "returns" => {
163                        let (parsed, next_i) = parse_numpy_returns(&lines, i + 2);
164                        returns = parsed;
165                        i = next_i;
166                        continue;
167                    }
168                    "raises" | "exceptions" => {
169                        let (parsed, next_i) = parse_numpy_raises(&lines, i + 2);
170                        raises = parsed;
171                        i = next_i;
172                        continue;
173                    }
174                    "examples" | "example" => {
175                        let (parsed, next_i) = parse_numpy_examples(&lines, i + 2);
176                        examples = parsed;
177                        i = next_i;
178                        continue;
179                    }
180                    _ => {}
181                }
182            }
183        }
184        i += 1;
185    }
186
187    ParsedDocstring {
188        summary,
189        description,
190        params,
191        returns,
192        raises,
193        examples,
194    }
195}
196
197/// Parse plain docstring (no structured sections)
198fn parse_plain(docstring: &str) -> ParsedDocstring {
199    let lines: Vec<&str> = docstring.lines().collect();
200    let (summary, description, _) = extract_summary_and_description(&lines);
201
202    ParsedDocstring {
203        summary,
204        description,
205        params: Vec::new(),
206        returns: None,
207        raises: Vec::new(),
208        examples: Vec::new(),
209    }
210}
211
212/// Extract summary and description from the beginning of a docstring
213fn extract_summary_and_description(lines: &[&str]) -> (Option<String>, Option<String>, usize) {
214    if lines.is_empty() {
215        return (None, None, 0);
216    }
217
218    let mut summary_lines = Vec::new();
219    let mut description_lines = Vec::new();
220    let mut in_description = false;
221    let mut i = 0;
222
223    // Collect summary (first paragraph)
224    while i < lines.len() {
225        let line = lines[i].trim();
226
227        // Empty line ends summary
228        if line.is_empty() {
229            if !summary_lines.is_empty() {
230                in_description = true;
231            }
232            i += 1;
233            continue;
234        }
235
236        // Check if this is a section header (Google style)
237        if line.ends_with(':') && !line.contains(' ') {
238            let section = &line[..line.len() - 1].to_lowercase();
239            if is_known_section(section) {
240                break;
241            }
242        }
243
244        // Check for NumPy style section (line followed by dashes)
245        if i + 1 < lines.len() {
246            let next_line = lines[i + 1].trim();
247            if next_line.chars().all(|c| c == '-')
248                && !next_line.is_empty()
249                && is_known_section(&line.to_lowercase())
250            {
251                break;
252            }
253        }
254
255        if in_description {
256            description_lines.push(line);
257        } else {
258            summary_lines.push(line);
259        }
260        i += 1;
261    }
262
263    let summary = if summary_lines.is_empty() {
264        None
265    } else {
266        Some(summary_lines.join(" "))
267    };
268
269    let description = if description_lines.is_empty() {
270        None
271    } else {
272        Some(description_lines.join("\n"))
273    };
274
275    (summary, description, i)
276}
277
278fn is_known_section(name: &str) -> bool {
279    matches!(
280        name,
281        "args"
282            | "arguments"
283            | "parameters"
284            | "params"
285            | "returns"
286            | "return"
287            | "raises"
288            | "raise"
289            | "exceptions"
290            | "except"
291            | "example"
292            | "examples"
293            | "attributes"
294            | "note"
295            | "notes"
296            | "yields"
297            | "yield"
298            | "see also"
299            | "references"
300            | "warnings"
301            | "warning"
302    )
303}
304
305/// Parse Google-style Args/Parameters section
306fn parse_google_params(lines: &[&str], start: usize) -> (Vec<ParamDoc>, usize) {
307    let mut params = Vec::new();
308    let mut i = start;
309    let mut current_name = String::new();
310    let mut current_ty: Option<String> = None;
311    let mut current_desc = Vec::new();
312
313    while i < lines.len() {
314        let line = lines[i];
315        let trimmed = line.trim();
316
317        // Empty line might end section
318        if trimmed.is_empty() {
319            // Save current param if any
320            if !current_name.is_empty() {
321                params.push(ParamDoc {
322                    name: current_name.clone(),
323                    ty: current_ty.clone(),
324                    description: current_desc.join(" ").trim().to_string(),
325                });
326                current_name.clear();
327                current_ty = None;
328                current_desc.clear();
329            }
330            i += 1;
331            continue;
332        }
333
334        // Check for new section header
335        if trimmed.ends_with(':') && !trimmed.contains(' ') {
336            let section = &trimmed[..trimmed.len() - 1].to_lowercase();
337            if is_known_section(section) {
338                break;
339            }
340        }
341
342        // Check if this is a new parameter (starts with non-space, contains colon)
343        let leading_spaces = line.len() - line.trim_start().len();
344
345        // New parameter line: "name (type): description" or "name: description"
346        if leading_spaces <= 4 && trimmed.contains(':') {
347            // Save previous param
348            if !current_name.is_empty() {
349                params.push(ParamDoc {
350                    name: current_name,
351                    ty: current_ty,
352                    description: current_desc.join(" ").trim().to_string(),
353                });
354            }
355
356            // Parse new param
357            let (name, ty, desc) = parse_param_line(trimmed);
358            current_name = name;
359            current_ty = ty;
360            current_desc = vec![desc];
361        } else if !current_name.is_empty() {
362            // Continuation of previous description
363            current_desc.push(trimmed.to_string());
364        }
365
366        i += 1;
367    }
368
369    // Don't forget the last parameter
370    if !current_name.is_empty() {
371        params.push(ParamDoc {
372            name: current_name,
373            ty: current_ty,
374            description: current_desc.join(" ").trim().to_string(),
375        });
376    }
377
378    (params, i)
379}
380
381/// Parse a Google-style parameter line: "name (type): description" or "name: description"
382fn parse_param_line(line: &str) -> (String, Option<String>, String) {
383    // First, find the colon that separates name/type from description
384    // The colon should come after any type annotation in parentheses
385
386    // Look for pattern "name (type): description"
387    // The key insight: the colon for the description comes after the closing paren
388    if let Some(colon_pos) = line.find(':') {
389        let before_colon = &line[..colon_pos];
390
391        // Check if there's a type annotation "(type)" before the colon
392        if let Some(paren_start) = before_colon.find('(')
393            && let Some(paren_end) = before_colon.rfind(')')
394            && paren_start < paren_end
395        {
396            let name = before_colon[..paren_start].trim().to_string();
397            let ty = before_colon[paren_start + 1..paren_end].trim().to_string();
398            let desc = line[colon_pos + 1..].trim().to_string();
399            return (name, Some(ty), desc);
400        }
401
402        // No type annotation, just "name: description"
403        let name = before_colon.trim().to_string();
404        let desc = line[colon_pos + 1..].trim().to_string();
405        return (name, None, desc);
406    }
407
408    (line.trim().to_string(), None, String::new())
409}
410
411/// Parse Google-style Returns section
412fn parse_google_returns(lines: &[&str], start: usize) -> (Option<ReturnDoc>, usize) {
413    let mut i = start;
414    let mut desc_lines = Vec::new();
415    let mut ty: Option<String> = None;
416
417    while i < lines.len() {
418        let line = lines[i];
419        let trimmed = line.trim();
420
421        if trimmed.is_empty() {
422            if !desc_lines.is_empty() {
423                break;
424            }
425            i += 1;
426            continue;
427        }
428
429        // Check for new section header
430        if trimmed.ends_with(':') && !trimmed.contains(' ') {
431            let section = &trimmed[..trimmed.len() - 1].to_lowercase();
432            if is_known_section(section) {
433                break;
434            }
435        }
436
437        // First non-empty line might have type: "type: description"
438        if desc_lines.is_empty() && trimmed.contains(':') {
439            let colon_pos = trimmed.find(':').unwrap();
440            let potential_type = &trimmed[..colon_pos];
441            // If it looks like a type (no spaces, reasonable length)
442            if !potential_type.contains(' ') || potential_type.contains('[') {
443                ty = Some(potential_type.trim().to_string());
444                desc_lines.push(trimmed[colon_pos + 1..].trim().to_string());
445            } else {
446                desc_lines.push(trimmed.to_string());
447            }
448        } else {
449            desc_lines.push(trimmed.to_string());
450        }
451
452        i += 1;
453    }
454
455    if desc_lines.is_empty() {
456        return (None, i);
457    }
458
459    let description = desc_lines.join(" ").trim().to_string();
460    (Some(ReturnDoc { ty, description }), i)
461}
462
463/// Parse Google-style Raises section
464fn parse_google_raises(lines: &[&str], start: usize) -> (Vec<RaisesDoc>, usize) {
465    let mut raises = Vec::new();
466    let mut i = start;
467    let mut current_ty = String::new();
468    let mut current_desc = Vec::new();
469
470    while i < lines.len() {
471        let line = lines[i];
472        let trimmed = line.trim();
473
474        if trimmed.is_empty() {
475            if !current_ty.is_empty() {
476                raises.push(RaisesDoc {
477                    ty: current_ty.clone(),
478                    description: current_desc.join(" ").trim().to_string(),
479                });
480                current_ty.clear();
481                current_desc.clear();
482            }
483            i += 1;
484            continue;
485        }
486
487        // Check for new section header
488        if trimmed.ends_with(':') && !trimmed.contains(' ') {
489            let section = &trimmed[..trimmed.len() - 1].to_lowercase();
490            if is_known_section(section) {
491                break;
492            }
493        }
494
495        let leading_spaces = line.len() - line.trim_start().len();
496
497        // New exception: "ExceptionType: description"
498        if leading_spaces <= 4 && trimmed.contains(':') {
499            if !current_ty.is_empty() {
500                raises.push(RaisesDoc {
501                    ty: current_ty,
502                    description: current_desc.join(" ").trim().to_string(),
503                });
504            }
505
506            let colon_pos = trimmed.find(':').unwrap();
507            current_ty = trimmed[..colon_pos].trim().to_string();
508            current_desc = vec![trimmed[colon_pos + 1..].trim().to_string()];
509        } else if !current_ty.is_empty() {
510            current_desc.push(trimmed.to_string());
511        }
512
513        i += 1;
514    }
515
516    if !current_ty.is_empty() {
517        raises.push(RaisesDoc {
518            ty: current_ty,
519            description: current_desc.join(" ").trim().to_string(),
520        });
521    }
522
523    (raises, i)
524}
525
526/// Parse Google-style Examples section
527fn parse_google_examples(lines: &[&str], start: usize) -> (Vec<String>, usize) {
528    let mut examples = Vec::new();
529    let mut current_example = Vec::new();
530    let mut in_code_block = false;
531    let mut i = start;
532
533    while i < lines.len() {
534        let line = lines[i];
535        let trimmed = line.trim();
536
537        // Check for new section header (but not if we're in a code block)
538        if !in_code_block && trimmed.ends_with(':') && !trimmed.contains(' ') {
539            let section = &trimmed[..trimmed.len() - 1].to_lowercase();
540            if is_known_section(section) {
541                break;
542            }
543        }
544
545        // Track code fence blocks
546        if trimmed.starts_with("```") {
547            in_code_block = !in_code_block;
548            current_example.push(line.to_string());
549            i += 1;
550            continue;
551        }
552
553        // Empty line outside code block might separate examples
554        if trimmed.is_empty() && !in_code_block {
555            if !current_example.is_empty() {
556                examples.push(current_example.join("\n"));
557                current_example.clear();
558            }
559            i += 1;
560            continue;
561        }
562
563        current_example.push(line.to_string());
564        i += 1;
565    }
566
567    if !current_example.is_empty() {
568        examples.push(current_example.join("\n"));
569    }
570
571    (examples, i)
572}
573
574/// Parse NumPy-style Parameters section
575fn parse_numpy_params(lines: &[&str], start: usize) -> (Vec<ParamDoc>, usize) {
576    let mut params = Vec::new();
577    let mut i = start;
578    let mut current_name = String::new();
579    let mut current_ty: Option<String> = None;
580    let mut current_desc = Vec::new();
581
582    while i < lines.len() {
583        let line = lines[i];
584        let trimmed = line.trim();
585
586        // Check for new section (line followed by dashes)
587        if i + 1 < lines.len() {
588            let next_line = lines[i + 1].trim();
589            if next_line.chars().all(|c| c == '-') && !next_line.is_empty() {
590                break;
591            }
592        }
593
594        if trimmed.is_empty() {
595            i += 1;
596            continue;
597        }
598
599        let leading_spaces = line.len() - line.trim_start().len();
600
601        // NumPy format: "param_name : type" on one line, description indented below
602        if leading_spaces == 0 && trimmed.contains(':') {
603            // Save previous
604            if !current_name.is_empty() {
605                params.push(ParamDoc {
606                    name: current_name,
607                    ty: current_ty,
608                    description: current_desc.join(" ").trim().to_string(),
609                });
610            }
611
612            let colon_pos = trimmed.find(':').unwrap();
613            current_name = trimmed[..colon_pos].trim().to_string();
614            let type_part = trimmed[colon_pos + 1..].trim();
615            current_ty = if type_part.is_empty() {
616                None
617            } else {
618                Some(type_part.to_string())
619            };
620            current_desc.clear();
621        } else if leading_spaces > 0 && !current_name.is_empty() {
622            // Description continuation
623            current_desc.push(trimmed.to_string());
624        }
625
626        i += 1;
627    }
628
629    if !current_name.is_empty() {
630        params.push(ParamDoc {
631            name: current_name,
632            ty: current_ty,
633            description: current_desc.join(" ").trim().to_string(),
634        });
635    }
636
637    (params, i)
638}
639
640/// Parse NumPy-style Returns section
641fn parse_numpy_returns(lines: &[&str], start: usize) -> (Option<ReturnDoc>, usize) {
642    let mut i = start;
643    let mut ty: Option<String> = None;
644    let mut desc_lines = Vec::new();
645
646    while i < lines.len() {
647        let line = lines[i];
648        let trimmed = line.trim();
649
650        // Check for new section
651        if i + 1 < lines.len() {
652            let next_line = lines[i + 1].trim();
653            if next_line.chars().all(|c| c == '-') && !next_line.is_empty() {
654                break;
655            }
656        }
657
658        if trimmed.is_empty() {
659            if !desc_lines.is_empty() || ty.is_some() {
660                break;
661            }
662            i += 1;
663            continue;
664        }
665
666        let leading_spaces = line.len() - line.trim_start().len();
667
668        // First line might be "type" or "name : type"
669        if ty.is_none() && leading_spaces == 0 {
670            if trimmed.contains(':') {
671                let colon_pos = trimmed.find(':').unwrap();
672                ty = Some(trimmed[colon_pos + 1..].trim().to_string());
673            } else {
674                ty = Some(trimmed.to_string());
675            }
676        } else if leading_spaces > 0 {
677            desc_lines.push(trimmed.to_string());
678        }
679
680        i += 1;
681    }
682
683    if ty.is_none() && desc_lines.is_empty() {
684        return (None, i);
685    }
686
687    (
688        Some(ReturnDoc {
689            ty,
690            description: desc_lines.join(" ").trim().to_string(),
691        }),
692        i,
693    )
694}
695
696/// Parse NumPy-style Raises section
697fn parse_numpy_raises(lines: &[&str], start: usize) -> (Vec<RaisesDoc>, usize) {
698    let mut raises = Vec::new();
699    let mut i = start;
700    let mut current_ty = String::new();
701    let mut current_desc = Vec::new();
702
703    while i < lines.len() {
704        let line = lines[i];
705        let trimmed = line.trim();
706
707        // Check for new section
708        if i + 1 < lines.len() {
709            let next_line = lines[i + 1].trim();
710            if next_line.chars().all(|c| c == '-') && !next_line.is_empty() {
711                break;
712            }
713        }
714
715        if trimmed.is_empty() {
716            i += 1;
717            continue;
718        }
719
720        let leading_spaces = line.len() - line.trim_start().len();
721
722        if leading_spaces == 0 {
723            // Save previous
724            if !current_ty.is_empty() {
725                raises.push(RaisesDoc {
726                    ty: current_ty,
727                    description: current_desc.join(" ").trim().to_string(),
728                });
729            }
730            current_ty = trimmed.to_string();
731            current_desc.clear();
732        } else if !current_ty.is_empty() {
733            current_desc.push(trimmed.to_string());
734        }
735
736        i += 1;
737    }
738
739    if !current_ty.is_empty() {
740        raises.push(RaisesDoc {
741            ty: current_ty,
742            description: current_desc.join(" ").trim().to_string(),
743        });
744    }
745
746    (raises, i)
747}
748
749/// Parse NumPy-style Examples section
750fn parse_numpy_examples(lines: &[&str], start: usize) -> (Vec<String>, usize) {
751    // NumPy examples are similar to Google style
752    parse_google_examples(lines, start)
753}
754
755impl ParsedDocstring {
756    /// Create an empty parsed docstring
757    pub fn empty() -> Self {
758        Self {
759            summary: None,
760            description: None,
761            params: Vec::new(),
762            returns: None,
763            raises: Vec::new(),
764            examples: Vec::new(),
765        }
766    }
767
768    /// Check if the docstring has any content
769    pub fn is_empty(&self) -> bool {
770        self.summary.is_none()
771            && self.description.is_none()
772            && self.params.is_empty()
773            && self.returns.is_none()
774            && self.raises.is_empty()
775            && self.examples.is_empty()
776    }
777}
778
779// ============================================================================
780// Rust Doc Comment Parser
781// ============================================================================
782
783/// Parse a Rust doc comment into structured form
784///
785/// Looks for conventional markdown sections:
786/// - `# Arguments` / `# Parameters` - function parameters
787/// - `# Returns` - return value documentation
788/// - `# Errors` - error conditions (maps to raises)
789/// - `# Panics` - panic conditions (maps to raises)
790/// - `# Safety` - safety requirements (stored in description)
791/// - `# Examples` - code examples
792///
793/// If parsing fails or no sections are found, returns a basic ParsedDocstring
794/// with just the summary/description extracted.
795pub fn parse_rust_doc(doc: &str) -> ParsedDocstring {
796    let doc = doc.trim();
797    if doc.is_empty() {
798        return ParsedDocstring::empty();
799    }
800
801    let lines: Vec<&str> = doc.lines().collect();
802
803    // Extract summary and description (everything before first # section)
804    let (summary, description, section_start) = extract_rust_summary(&lines);
805
806    let mut params = Vec::new();
807    let mut returns = None;
808    let mut raises = Vec::new();
809    let mut examples = Vec::new();
810    let mut safety_notes = Vec::new();
811
812    // Parse sections
813    let mut i = section_start;
814    while i < lines.len() {
815        let line = lines[i].trim();
816
817        // Check for markdown header: # Section or ## Section
818        if let Some(section_name) = parse_markdown_header(line) {
819            let section_lower = section_name.to_lowercase();
820            match section_lower.as_str() {
821                "arguments" | "parameters" | "args" | "params" => {
822                    let (parsed, next_i) = parse_rust_arguments(&lines, i + 1);
823                    params = parsed;
824                    i = next_i;
825                }
826                "returns" | "return" => {
827                    let (parsed, next_i) = parse_rust_returns(&lines, i + 1);
828                    returns = parsed;
829                    i = next_i;
830                }
831                "errors" | "error" => {
832                    let (parsed, next_i) = parse_rust_errors(&lines, i + 1, "Error");
833                    raises.extend(parsed);
834                    i = next_i;
835                }
836                "panics" | "panic" => {
837                    let (parsed, next_i) = parse_rust_errors(&lines, i + 1, "Panic");
838                    raises.extend(parsed);
839                    i = next_i;
840                }
841                "safety" => {
842                    let (notes, next_i) = parse_rust_section_text(&lines, i + 1);
843                    safety_notes.push(notes);
844                    i = next_i;
845                }
846                "examples" | "example" => {
847                    let (parsed, next_i) = parse_rust_examples(&lines, i + 1);
848                    examples = parsed;
849                    i = next_i;
850                }
851                _ => {
852                    // Unknown section, skip
853                    i += 1;
854                }
855            }
856        } else {
857            i += 1;
858        }
859    }
860
861    // If we found safety notes, append to description
862    let final_description = if safety_notes.is_empty() {
863        description
864    } else {
865        let safety_text = format!("\n\n# Safety\n{}", safety_notes.join("\n"));
866        match description {
867            Some(desc) => Some(format!("{}{}", desc, safety_text)),
868            None => Some(safety_text.trim_start().to_string()),
869        }
870    };
871
872    ParsedDocstring {
873        summary,
874        description: final_description,
875        params,
876        returns,
877        raises,
878        examples,
879    }
880}
881
882/// Extract summary and description from Rust doc before any # sections
883fn extract_rust_summary(lines: &[&str]) -> (Option<String>, Option<String>, usize) {
884    if lines.is_empty() {
885        return (None, None, 0);
886    }
887
888    let mut summary_lines = Vec::new();
889    let mut description_lines = Vec::new();
890    let mut in_description = false;
891    let mut i = 0;
892
893    while i < lines.len() {
894        let line = lines[i].trim();
895
896        // Check for markdown header - this starts a section
897        if parse_markdown_header(line).is_some() {
898            break;
899        }
900
901        // Empty line transitions from summary to description
902        if line.is_empty() {
903            if !summary_lines.is_empty() {
904                in_description = true;
905            }
906            i += 1;
907            continue;
908        }
909
910        if in_description {
911            description_lines.push(line);
912        } else {
913            summary_lines.push(line);
914        }
915        i += 1;
916    }
917
918    let summary = if summary_lines.is_empty() {
919        None
920    } else {
921        Some(summary_lines.join(" "))
922    };
923
924    let description = if description_lines.is_empty() {
925        None
926    } else {
927        Some(description_lines.join("\n"))
928    };
929
930    (summary, description, i)
931}
932
933/// Parse a markdown header line, returning the section name if found
934fn parse_markdown_header(line: &str) -> Option<&str> {
935    let trimmed = line.trim();
936
937    // Match # Header or ## Header (up to 3 levels)
938    if let Some(rest) = trimmed.strip_prefix("### ") {
939        Some(rest.trim())
940    } else if let Some(rest) = trimmed.strip_prefix("## ") {
941        Some(rest.trim())
942    } else if let Some(rest) = trimmed.strip_prefix("# ") {
943        Some(rest.trim())
944    } else {
945        None
946    }
947}
948
949/// Parse Rust-style Arguments section
950///
951/// Expects format like:
952/// ```text
953/// * `name` - Description of the parameter
954/// * `other` - Another parameter
955/// ```
956/// or
957/// ```text
958/// - `name`: Description
959/// ```
960fn parse_rust_arguments(lines: &[&str], start: usize) -> (Vec<ParamDoc>, usize) {
961    let mut params = Vec::new();
962    let mut i = start;
963    let mut current_name = String::new();
964    let mut current_desc = Vec::new();
965
966    while i < lines.len() {
967        let line = lines[i];
968        let trimmed = line.trim();
969
970        // Stop at next section
971        if parse_markdown_header(trimmed).is_some() {
972            break;
973        }
974
975        // Empty line might end the section
976        if trimmed.is_empty() {
977            if !current_name.is_empty() {
978                params.push(ParamDoc {
979                    name: current_name.clone(),
980                    ty: None,
981                    description: current_desc.join(" ").trim().to_string(),
982                });
983                current_name.clear();
984                current_desc.clear();
985            }
986            i += 1;
987            continue;
988        }
989
990        // Look for list item: * `name` - desc or - `name`: desc
991        if let Some(param) = parse_rust_param_line(trimmed) {
992            // Save previous
993            if !current_name.is_empty() {
994                params.push(ParamDoc {
995                    name: current_name,
996                    ty: None,
997                    description: current_desc.join(" ").trim().to_string(),
998                });
999            }
1000            current_name = param.0;
1001            current_desc = vec![param.1];
1002        } else if !current_name.is_empty()
1003            && (trimmed.starts_with(' ') || !trimmed.starts_with('*') && !trimmed.starts_with('-'))
1004        {
1005            // Continuation line
1006            current_desc.push(trimmed.to_string());
1007        }
1008
1009        i += 1;
1010    }
1011
1012    // Don't forget the last one
1013    if !current_name.is_empty() {
1014        params.push(ParamDoc {
1015            name: current_name,
1016            ty: None,
1017            description: current_desc.join(" ").trim().to_string(),
1018        });
1019    }
1020
1021    (params, i)
1022}
1023
1024/// Parse a single Rust parameter line
1025/// Formats: `* `name` - description` or `- `name`: description` or `* name - description`
1026fn parse_rust_param_line(line: &str) -> Option<(String, String)> {
1027    let trimmed = line.trim();
1028
1029    // Must start with * or -
1030    if !trimmed.starts_with('*') && !trimmed.starts_with('-') {
1031        return None;
1032    }
1033
1034    let rest = trimmed[1..].trim();
1035
1036    // Try to find backtick-quoted name: `name`
1037    if rest.starts_with('`')
1038        && let Some(end_tick) = rest[1..].find('`')
1039    {
1040        let name = rest[1..end_tick + 1].to_string();
1041        let after_name = rest[end_tick + 2..].trim();
1042
1043        // Look for separator: - or :
1044        let desc = if let Some(rest) = after_name
1045            .strip_prefix('-')
1046            .or_else(|| after_name.strip_prefix(':'))
1047        {
1048            rest.trim().to_string()
1049        } else {
1050            after_name.to_string()
1051        };
1052
1053        return Some((name, desc));
1054    }
1055
1056    // Try plain format: name - description
1057    if let Some(sep_pos) = rest.find(" - ") {
1058        let name = rest[..sep_pos].trim().to_string();
1059        let desc = rest[sep_pos + 3..].trim().to_string();
1060        return Some((name, desc));
1061    }
1062
1063    // Try colon format: name: description
1064    if let Some(sep_pos) = rest.find(':') {
1065        let name = rest[..sep_pos].trim().to_string();
1066        let desc = rest[sep_pos + 1..].trim().to_string();
1067        return Some((name, desc));
1068    }
1069
1070    None
1071}
1072
1073/// Parse Rust-style Returns section
1074fn parse_rust_returns(lines: &[&str], start: usize) -> (Option<ReturnDoc>, usize) {
1075    let (text, next_i) = parse_rust_section_text(lines, start);
1076
1077    if text.is_empty() {
1078        return (None, next_i);
1079    }
1080
1081    (
1082        Some(ReturnDoc {
1083            ty: None,
1084            description: text,
1085        }),
1086        next_i,
1087    )
1088}
1089
1090/// Parse Rust-style Errors/Panics section
1091fn parse_rust_errors(lines: &[&str], start: usize, error_kind: &str) -> (Vec<RaisesDoc>, usize) {
1092    let mut raises = Vec::new();
1093    let mut i = start;
1094    let mut current_ty = String::new();
1095    let mut current_desc = Vec::new();
1096
1097    while i < lines.len() {
1098        let line = lines[i];
1099        let trimmed = line.trim();
1100
1101        // Stop at next section
1102        if parse_markdown_header(trimmed).is_some() {
1103            break;
1104        }
1105
1106        if trimmed.is_empty() {
1107            if !current_ty.is_empty() || !current_desc.is_empty() {
1108                raises.push(RaisesDoc {
1109                    ty: if current_ty.is_empty() {
1110                        error_kind.to_string()
1111                    } else {
1112                        current_ty.clone()
1113                    },
1114                    description: current_desc.join(" ").trim().to_string(),
1115                });
1116                current_ty.clear();
1117                current_desc.clear();
1118            }
1119            i += 1;
1120            continue;
1121        }
1122
1123        // Check for list item with error type: * `ErrorType` - when...
1124        if trimmed.starts_with('*') || trimmed.starts_with('-') {
1125            // Save previous
1126            if !current_ty.is_empty() || !current_desc.is_empty() {
1127                raises.push(RaisesDoc {
1128                    ty: if current_ty.is_empty() {
1129                        error_kind.to_string()
1130                    } else {
1131                        current_ty
1132                    },
1133                    description: current_desc.join(" ").trim().to_string(),
1134                });
1135            }
1136
1137            let rest = trimmed[1..].trim();
1138
1139            // Try to extract error type from backticks
1140            if let Some(after_tick) = rest.strip_prefix('`') {
1141                if let Some(end_tick) = after_tick.find('`') {
1142                    current_ty = after_tick[..end_tick].to_string();
1143                    let after = after_tick[end_tick + 1..].trim();
1144                    current_desc = vec![
1145                        after
1146                            .trim_start_matches('-')
1147                            .trim_start_matches(':')
1148                            .trim()
1149                            .to_string(),
1150                    ];
1151                } else {
1152                    current_ty = error_kind.to_string();
1153                    current_desc = vec![rest.to_string()];
1154                }
1155            } else {
1156                current_ty = error_kind.to_string();
1157                current_desc = vec![rest.to_string()];
1158            }
1159        } else if !current_desc.is_empty() {
1160            // Continuation
1161            current_desc.push(trimmed.to_string());
1162        } else {
1163            // Plain text, no list format
1164            current_ty = error_kind.to_string();
1165            current_desc.push(trimmed.to_string());
1166        }
1167
1168        i += 1;
1169    }
1170
1171    // Don't forget the last one
1172    if !current_ty.is_empty() || !current_desc.is_empty() {
1173        raises.push(RaisesDoc {
1174            ty: if current_ty.is_empty() {
1175                error_kind.to_string()
1176            } else {
1177                current_ty
1178            },
1179            description: current_desc.join(" ").trim().to_string(),
1180        });
1181    }
1182
1183    (raises, i)
1184}
1185
1186/// Parse Rust-style Examples section
1187fn parse_rust_examples(lines: &[&str], start: usize) -> (Vec<String>, usize) {
1188    let mut examples = Vec::new();
1189    let mut current_example = Vec::new();
1190    let mut in_code_block = false;
1191    let mut i = start;
1192
1193    while i < lines.len() {
1194        let line = lines[i];
1195        let trimmed = line.trim();
1196
1197        // Stop at next section (but not if we're in a code block)
1198        if !in_code_block && parse_markdown_header(trimmed).is_some() {
1199            break;
1200        }
1201
1202        // Track code fence blocks
1203        if trimmed.starts_with("```") {
1204            in_code_block = !in_code_block;
1205            current_example.push(line.to_string());
1206            i += 1;
1207            continue;
1208        }
1209
1210        // Empty line outside code block might separate examples
1211        if trimmed.is_empty() && !in_code_block {
1212            if !current_example.is_empty() {
1213                examples.push(current_example.join("\n"));
1214                current_example.clear();
1215            }
1216            i += 1;
1217            continue;
1218        }
1219
1220        current_example.push(line.to_string());
1221        i += 1;
1222    }
1223
1224    if !current_example.is_empty() {
1225        examples.push(current_example.join("\n"));
1226    }
1227
1228    (examples, i)
1229}
1230
1231/// Parse a section as plain text until next section
1232fn parse_rust_section_text(lines: &[&str], start: usize) -> (String, usize) {
1233    let mut text_lines = Vec::new();
1234    let mut i = start;
1235
1236    while i < lines.len() {
1237        let line = lines[i];
1238        let trimmed = line.trim();
1239
1240        // Stop at next section
1241        if parse_markdown_header(trimmed).is_some() {
1242            break;
1243        }
1244
1245        // Skip leading empty lines
1246        if trimmed.is_empty() && text_lines.is_empty() {
1247            i += 1;
1248            continue;
1249        }
1250
1251        text_lines.push(trimmed);
1252        i += 1;
1253    }
1254
1255    // Trim trailing empty lines
1256    while text_lines.last().map(|s| s.is_empty()).unwrap_or(false) {
1257        text_lines.pop();
1258    }
1259
1260    (text_lines.join(" ").trim().to_string(), i)
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265    use super::*;
1266
1267    #[test]
1268    fn test_parse_empty() {
1269        let result = parse_docstring("");
1270        assert!(result.is_empty());
1271    }
1272
1273    #[test]
1274    fn test_parse_summary_only() {
1275        let docstring = "A simple summary.";
1276        let result = parse_docstring(docstring);
1277
1278        assert_eq!(result.summary, Some("A simple summary.".to_string()));
1279        assert!(result.description.is_none());
1280        assert!(result.params.is_empty());
1281    }
1282
1283    #[test]
1284    fn test_parse_summary_and_description() {
1285        let docstring = "A short summary.
1286
1287This is a longer description that spans
1288multiple lines and provides more detail.";
1289
1290        let result = parse_docstring(docstring);
1291
1292        assert_eq!(result.summary, Some("A short summary.".to_string()));
1293        assert!(result.description.is_some());
1294        assert!(
1295            result
1296                .description
1297                .as_ref()
1298                .unwrap()
1299                .contains("longer description")
1300        );
1301    }
1302
1303    #[test]
1304    fn test_parse_google_args() {
1305        let docstring = "Do something.
1306
1307Args:
1308    name: The name of the thing.
1309    value (int): The value to use.
1310    optional: An optional parameter that
1311        spans multiple lines.
1312";
1313
1314        let result = parse_docstring(docstring);
1315
1316        assert_eq!(result.summary, Some("Do something.".to_string()));
1317        assert_eq!(result.params.len(), 3);
1318
1319        assert_eq!(result.params[0].name, "name");
1320        assert!(result.params[0].ty.is_none());
1321        assert_eq!(result.params[0].description, "The name of the thing.");
1322
1323        assert_eq!(result.params[1].name, "value");
1324        assert_eq!(result.params[1].ty, Some("int".to_string()));
1325        assert_eq!(result.params[1].description, "The value to use.");
1326
1327        assert_eq!(result.params[2].name, "optional");
1328        assert!(result.params[2].description.contains("multiple lines"));
1329    }
1330
1331    #[test]
1332    fn test_parse_google_returns() {
1333        let docstring = "Calculate result.
1334
1335Returns:
1336    The calculated result as an integer.
1337";
1338
1339        let result = parse_docstring(docstring);
1340
1341        assert!(result.returns.is_some());
1342        let ret = result.returns.unwrap();
1343        assert!(ret.description.contains("calculated result"));
1344    }
1345
1346    #[test]
1347    fn test_parse_google_returns_with_type() {
1348        let docstring = "Get value.
1349
1350Returns:
1351    int: The integer value.
1352";
1353
1354        let result = parse_docstring(docstring);
1355
1356        assert!(result.returns.is_some());
1357        let ret = result.returns.unwrap();
1358        assert_eq!(ret.ty, Some("int".to_string()));
1359        assert_eq!(ret.description, "The integer value.");
1360    }
1361
1362    #[test]
1363    fn test_parse_google_raises() {
1364        let docstring = "Do dangerous thing.
1365
1366Raises:
1367    ValueError: If the value is invalid.
1368    RuntimeError: If something goes wrong
1369        during execution.
1370";
1371
1372        let result = parse_docstring(docstring);
1373
1374        assert_eq!(result.raises.len(), 2);
1375        assert_eq!(result.raises[0].ty, "ValueError");
1376        assert!(result.raises[0].description.contains("invalid"));
1377        assert_eq!(result.raises[1].ty, "RuntimeError");
1378        assert!(result.raises[1].description.contains("execution"));
1379    }
1380
1381    #[test]
1382    fn test_parse_google_examples() {
1383        let docstring = "Do something.
1384
1385Example:
1386    >>> x = do_something()
1387    >>> print(x)
1388    42
1389";
1390
1391        let result = parse_docstring(docstring);
1392
1393        assert_eq!(result.examples.len(), 1);
1394        assert!(result.examples[0].contains(">>> x = do_something()"));
1395    }
1396
1397    #[test]
1398    fn test_parse_google_examples_with_code_fence() {
1399        // Test that code fences inside examples are preserved as a single example
1400        // and not split at empty lines within the fence
1401        let docstring = r#"A data processing pipeline.
1402
1403Example:
1404    ```python
1405    from separate_bindings import Pipeline, DataBatch
1406
1407    pipeline = Pipeline("etl")
1408    pipeline.add_stage("transform", lambda batch: batch)
1409
1410    result = pipeline.run(DataBatch.from_dicts([{"a": 1}]))
1411    print(f"Processed {result.rows_out} rows")
1412    ```
1413"#;
1414
1415        let result = parse_docstring(docstring);
1416
1417        // Should be exactly one example, not split at the empty lines
1418        assert_eq!(result.examples.len(), 1);
1419        // Should contain the code fence markers
1420        assert!(result.examples[0].contains("```python"));
1421        assert!(result.examples[0].contains("```"));
1422        // Should contain all the code
1423        assert!(result.examples[0].contains("Pipeline"));
1424        assert!(result.examples[0].contains("rows_out"));
1425    }
1426
1427    #[test]
1428    fn test_parse_google_full() {
1429        let docstring = "Create a new task runner.
1430
1431Args:
1432    max_parallel: Maximum number of concurrent tasks (default: 4).
1433
1434Returns:
1435    A new Runner instance.
1436
1437Raises:
1438    RuntimeError: If initialization fails.
1439
1440Example:
1441    >>> runner = Runner(max_parallel=8)
1442";
1443
1444        let result = parse_docstring(docstring);
1445
1446        assert_eq!(
1447            result.summary,
1448            Some("Create a new task runner.".to_string())
1449        );
1450        assert_eq!(result.params.len(), 1);
1451        assert_eq!(result.params[0].name, "max_parallel");
1452        assert!(result.returns.is_some());
1453        assert_eq!(result.raises.len(), 1);
1454        assert_eq!(result.examples.len(), 1);
1455    }
1456
1457    #[test]
1458    fn test_parse_numpy_style() {
1459        let docstring = "Calculate the mean.
1460
1461Parameters
1462----------
1463values : array-like
1464    The values to average.
1465weights : array-like, optional
1466    Optional weights.
1467
1468Returns
1469-------
1470float
1471    The weighted mean.
1472
1473Raises
1474------
1475ValueError
1476    If arrays have different lengths.
1477";
1478
1479        let result = parse_docstring(docstring);
1480
1481        assert_eq!(result.summary, Some("Calculate the mean.".to_string()));
1482
1483        assert_eq!(result.params.len(), 2);
1484        assert_eq!(result.params[0].name, "values");
1485        assert_eq!(result.params[0].ty, Some("array-like".to_string()));
1486        assert_eq!(result.params[1].name, "weights");
1487
1488        assert!(result.returns.is_some());
1489        let ret = result.returns.unwrap();
1490        assert_eq!(ret.ty, Some("float".to_string()));
1491
1492        assert_eq!(result.raises.len(), 1);
1493        assert_eq!(result.raises[0].ty, "ValueError");
1494    }
1495
1496    #[test]
1497    fn test_parse_rust_docstring() {
1498        // Rust docstrings are similar to Google style
1499        let docstring = "Create a new task.
1500
1501Args:
1502    name: The unique identifier for this task.
1503    description: Optional human-readable description.
1504
1505Returns:
1506    A new Task instance.";
1507
1508        let result = parse_docstring(docstring);
1509
1510        assert_eq!(result.summary, Some("Create a new task.".to_string()));
1511        assert_eq!(result.params.len(), 2);
1512        assert!(result.returns.is_some());
1513    }
1514
1515    #[test]
1516    fn test_detect_google_style() {
1517        assert_eq!(
1518            detect_style("Summary.\n\nArgs:\n    x: value"),
1519            DocstringStyle::Google
1520        );
1521        assert_eq!(
1522            detect_style("Summary.\n\nReturns:\n    value"),
1523            DocstringStyle::Google
1524        );
1525    }
1526
1527    #[test]
1528    fn test_detect_numpy_style() {
1529        assert_eq!(
1530            detect_style("Summary.\n\nParameters\n----------\n"),
1531            DocstringStyle::NumPy
1532        );
1533    }
1534
1535    #[test]
1536    fn test_detect_plain_style() {
1537        assert_eq!(
1538            detect_style("Just a simple docstring."),
1539            DocstringStyle::Plain
1540        );
1541    }
1542
1543    #[test]
1544    fn test_parse_scheduler_docstring() {
1545        // Test with the actual scheduler.py docstring
1546        let docstring = r#"A task scheduler that runs tasks on configured schedules.
1547
1548The scheduler supports both interval-based and cron-based scheduling.
1549Tasks are registered using the `@scheduler.task()` decorator.
1550
1551Attributes:
1552    tasks: Dictionary of registered tasks by name.
1553    running: Whether the scheduler is currently running.
1554
1555Example:
1556    >>> scheduler = Scheduler()
1557    >>> @scheduler.task(every(seconds=30))
1558    ... def heartbeat():
1559    ...     print("alive")
1560    >>> scheduler.run()"#;
1561
1562        let result = parse_docstring(docstring);
1563
1564        assert!(result.summary.is_some());
1565        assert!(result.summary.as_ref().unwrap().contains("task scheduler"));
1566        assert!(result.description.is_some());
1567        assert_eq!(result.examples.len(), 1);
1568    }
1569
1570    #[test]
1571    fn test_parse_fixture_method_docstring() {
1572        let docstring = r#"Decorator to register a function as a scheduled task.
1573
1574Args:
1575    schedule: When to run the task (use `every()` or `cron()`).
1576    name: Optional task name. Defaults to the function name.
1577    max_retries: Number of retry attempts on failure.
1578    timeout_seconds: Maximum execution time before killing the task.
1579
1580Returns:
1581    A decorator that registers the function.
1582
1583Raises:
1584    ValueError: If a task with this name already exists.
1585
1586Example:
1587    >>> @scheduler.task(every(hours=1), max_retries=3)
1588    ... def sync_data():
1589    ...     external_api.sync()"#;
1590
1591        let result = parse_docstring(docstring);
1592
1593        assert_eq!(result.params.len(), 4);
1594        assert_eq!(result.params[0].name, "schedule");
1595        assert!(result.returns.is_some());
1596        assert_eq!(result.raises.len(), 1);
1597        assert_eq!(result.raises[0].ty, "ValueError");
1598        assert_eq!(result.examples.len(), 1);
1599    }
1600
1601    // ========================================================================
1602    // Rust Doc Comment Parser Tests
1603    // ========================================================================
1604
1605    #[test]
1606    fn test_parse_rust_doc_empty() {
1607        let result = parse_rust_doc("");
1608        assert!(result.is_empty());
1609    }
1610
1611    #[test]
1612    fn test_parse_rust_doc_summary_only() {
1613        let doc = "Returns the length of the string.";
1614        let result = parse_rust_doc(doc);
1615
1616        assert_eq!(
1617            result.summary,
1618            Some("Returns the length of the string.".to_string())
1619        );
1620        assert!(result.description.is_none());
1621    }
1622
1623    #[test]
1624    fn test_parse_rust_doc_summary_and_description() {
1625        let doc = "Returns the length of the string.
1626
1627This is a longer description that provides
1628more detail about how the function works.";
1629
1630        let result = parse_rust_doc(doc);
1631
1632        assert_eq!(
1633            result.summary,
1634            Some("Returns the length of the string.".to_string())
1635        );
1636        assert!(result.description.is_some());
1637        assert!(
1638            result
1639                .description
1640                .as_ref()
1641                .unwrap()
1642                .contains("longer description")
1643        );
1644    }
1645
1646    #[test]
1647    fn test_parse_rust_doc_arguments() {
1648        let doc = "Creates a new buffer.
1649
1650# Arguments
1651
1652* `capacity` - The initial capacity of the buffer
1653* `fill` - The value to fill the buffer with";
1654
1655        let result = parse_rust_doc(doc);
1656
1657        assert_eq!(result.params.len(), 2);
1658        assert_eq!(result.params[0].name, "capacity");
1659        assert!(result.params[0].description.contains("initial capacity"));
1660        assert_eq!(result.params[1].name, "fill");
1661    }
1662
1663    #[test]
1664    fn test_parse_rust_doc_arguments_backticks() {
1665        let doc = "Process data.
1666
1667# Arguments
1668
1669* `data` - The data to process
1670* `options` - Processing options";
1671
1672        let result = parse_rust_doc(doc);
1673
1674        assert_eq!(result.params.len(), 2);
1675        assert_eq!(result.params[0].name, "data");
1676        assert_eq!(result.params[1].name, "options");
1677    }
1678
1679    #[test]
1680    fn test_parse_rust_doc_returns() {
1681        let doc = "Computes the hash.
1682
1683# Returns
1684
1685The computed hash value as a 64-bit integer.";
1686
1687        let result = parse_rust_doc(doc);
1688
1689        assert!(result.returns.is_some());
1690        let ret = result.returns.unwrap();
1691        assert!(ret.description.contains("64-bit integer"));
1692    }
1693
1694    #[test]
1695    fn test_parse_rust_doc_errors() {
1696        let doc = "Opens a file.
1697
1698# Errors
1699
1700Returns an error if the file does not exist or
1701cannot be opened.";
1702
1703        let result = parse_rust_doc(doc);
1704
1705        assert_eq!(result.raises.len(), 1);
1706        assert_eq!(result.raises[0].ty, "Error");
1707        assert!(result.raises[0].description.contains("file does not exist"));
1708    }
1709
1710    #[test]
1711    fn test_parse_rust_doc_errors_with_types() {
1712        let doc = "Parses the input.
1713
1714# Errors
1715
1716* `ParseError` - If the input is malformed
1717* `IoError` - If reading fails";
1718
1719        let result = parse_rust_doc(doc);
1720
1721        assert_eq!(result.raises.len(), 2);
1722        assert_eq!(result.raises[0].ty, "ParseError");
1723        assert!(result.raises[0].description.contains("malformed"));
1724        assert_eq!(result.raises[1].ty, "IoError");
1725    }
1726
1727    #[test]
1728    fn test_parse_rust_doc_panics() {
1729        let doc = "Gets the element.
1730
1731# Panics
1732
1733Panics if the index is out of bounds.";
1734
1735        let result = parse_rust_doc(doc);
1736
1737        assert_eq!(result.raises.len(), 1);
1738        assert_eq!(result.raises[0].ty, "Panic");
1739        assert!(result.raises[0].description.contains("out of bounds"));
1740    }
1741
1742    #[test]
1743    fn test_parse_rust_doc_examples() {
1744        let doc = r#"Creates a new instance.
1745
1746# Examples
1747
1748```rust
1749let x = MyType::new();
1750assert!(x.is_valid());
1751```"#;
1752
1753        let result = parse_rust_doc(doc);
1754
1755        assert_eq!(result.examples.len(), 1);
1756        assert!(result.examples[0].contains("let x = MyType::new()"));
1757        assert!(result.examples[0].contains("```"));
1758    }
1759
1760    #[test]
1761    fn test_parse_rust_doc_safety() {
1762        let doc = "Dereferences a raw pointer.
1763
1764# Safety
1765
1766The pointer must be valid and properly aligned.
1767The caller must ensure the pointed-to data is valid.";
1768
1769        let result = parse_rust_doc(doc);
1770
1771        assert!(result.description.is_some());
1772        assert!(result.description.as_ref().unwrap().contains("Safety"));
1773        assert!(
1774            result
1775                .description
1776                .as_ref()
1777                .unwrap()
1778                .contains("pointer must be valid")
1779        );
1780    }
1781
1782    #[test]
1783    fn test_parse_rust_doc_full() {
1784        let doc = r#"Processes the input data and returns the result.
1785
1786This function performs complex processing on the input,
1787applying various transformations.
1788
1789# Arguments
1790
1791* `input` - The input data to process
1792* `config` - Configuration options
1793
1794# Returns
1795
1796The processed result, or an error if processing fails.
1797
1798# Errors
1799
1800* `InvalidInput` - If the input is malformed
1801* `ProcessingError` - If processing fails
1802
1803# Panics
1804
1805Panics if the config is invalid.
1806
1807# Examples
1808
1809```rust
1810let result = process(&data, &config)?;
1811println!("{:?}", result);
1812```"#;
1813
1814        let result = parse_rust_doc(doc);
1815
1816        assert!(result.summary.is_some());
1817        assert!(
1818            result
1819                .summary
1820                .as_ref()
1821                .unwrap()
1822                .contains("Processes the input")
1823        );
1824        assert!(result.description.is_some());
1825        assert_eq!(result.params.len(), 2);
1826        assert_eq!(result.params[0].name, "input");
1827        assert!(result.returns.is_some());
1828        assert_eq!(result.raises.len(), 3); // 2 errors + 1 panic
1829        assert_eq!(result.examples.len(), 1);
1830    }
1831
1832    #[test]
1833    fn test_parse_rust_doc_no_sections() {
1834        // Plain doc with no sections should still work
1835        let doc = "A simple function that does something useful.";
1836        let result = parse_rust_doc(doc);
1837
1838        assert_eq!(
1839            result.summary,
1840            Some("A simple function that does something useful.".to_string())
1841        );
1842        assert!(result.params.is_empty());
1843        assert!(result.returns.is_none());
1844    }
1845
1846    #[test]
1847    fn test_parse_markdown_header() {
1848        assert_eq!(parse_markdown_header("# Arguments"), Some("Arguments"));
1849        assert_eq!(parse_markdown_header("## Returns"), Some("Returns"));
1850        assert_eq!(parse_markdown_header("### Examples"), Some("Examples"));
1851        assert_eq!(parse_markdown_header("Not a header"), None);
1852        assert_eq!(parse_markdown_header("#NoSpace"), None);
1853    }
1854}