acp/annotate/converters/
docstring.rs

1//! @acp:module "Python Docstring Parser"
2//! @acp:summary "Parses Python docstrings and converts to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Python Docstring Parser
8//!
9//! Parses Python docstrings in multiple formats:
10//!
11//! ## Google Style
12//! - Args/Parameters, Returns, Yields, Raises/Exceptions
13//! - Attributes, Example/Examples, Note/Notes, Warning/Warnings
14//! - See Also, Todo, References, Deprecated
15//!
16//! ## NumPy Style
17//! - Parameters, Other Parameters, Returns, Yields, Receives
18//! - Raises, Warns, See Also, Notes, References, Examples
19//! - Attributes, Methods
20//!
21//! ## Sphinx/reST Style
22//! - :param:, :type:, :returns:, :rtype:
23//! - :raises:, :deprecated:, :version:, :since:
24//! - :seealso:, :note:, :warning:, :example:, :todo:
25//! - :var:, :ivar:, :cvar:, :meta:
26//!
27//! ## Plain Style
28//! - First line = summary, rest = description
29
30use std::sync::LazyLock;
31
32use regex::Regex;
33
34use super::{DocStandardParser, ParsedDocumentation};
35use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
36
37/// @acp:summary "Matches Google-style section headers"
38static GOOGLE_SECTION: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(
40        r"^(Args|Arguments|Parameters|Returns|Return|Yields|Yield|Receives|Raises|Exceptions|Warns|Attributes|Example|Examples|Note|Notes|Warning|Warnings|See Also|Todo|Todos|References|Deprecated|Other Parameters|Keyword Args|Keyword Arguments|Methods|Class Attributes|Version|Since):\s*$"
41    ).expect("Invalid Google section regex")
42});
43
44/// @acp:summary "Matches Sphinx-style tags"
45static SPHINX_TAG: LazyLock<Regex> = LazyLock::new(|| {
46    Regex::new(
47        r"^:(param|type|returns|rtype|raises|raise|var|ivar|cvar|deprecated|version|since|seealso|see|note|warning|example|todo|meta|keyword|kwarg|kwparam)(\s+\w+)?:\s*(.*)$"
48    ).expect("Invalid Sphinx tag regex")
49});
50
51/// @acp:summary "Matches Google-style parameter lines (name (type): desc or name: desc)"
52static GOOGLE_PARAM: LazyLock<Regex> = LazyLock::new(|| {
53    Regex::new(r"^\s*(\w+)(?:\s*\(([^)]+)\))?:\s*(.*)$").expect("Invalid Google param regex")
54});
55
56/// @acp:summary "Matches NumPy-style parameter lines (name : type)"
57static NUMPY_PARAM: LazyLock<Regex> = LazyLock::new(|| {
58    Regex::new(r"^(\w+)\s*:\s*([^,\n]+)(?:,\s*optional)?$").expect("Invalid NumPy param regex")
59});
60
61/// @acp:summary "Python-specific extensions for docstrings"
62#[derive(Debug, Clone, Default)]
63pub struct PythonDocExtensions {
64    /// Whether this is a generator function (has Yields)
65    pub is_generator: bool,
66
67    /// Whether this is an async function (has Receives for async generators)
68    pub is_async_generator: bool,
69
70    /// Class attributes (for class docstrings)
71    pub class_attributes: Vec<(String, Option<String>, Option<String>)>,
72
73    /// Instance variables
74    pub instance_vars: Vec<(String, Option<String>, Option<String>)>,
75
76    /// Class variables
77    pub class_vars: Vec<(String, Option<String>, Option<String>)>,
78
79    /// Methods (for class docstrings)
80    pub methods: Vec<(String, Option<String>)>,
81
82    /// Keyword arguments (separate from regular params)
83    pub kwargs: Vec<(String, Option<String>, Option<String>)>,
84
85    /// Version info
86    pub version: Option<String>,
87
88    /// Since version
89    pub since: Option<String>,
90
91    /// Warnings (distinct from notes)
92    pub warnings: Vec<String>,
93
94    /// Meta information
95    pub meta: Vec<(String, String)>,
96}
97
98/// @acp:summary "Python docstring style"
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DocstringStyle {
101    /// Google style with sections
102    Google,
103    /// NumPy style with underlined sections
104    NumPy,
105    /// Sphinx/reST style with :tags:
106    Sphinx,
107    /// Plain docstring (first line = summary)
108    Plain,
109}
110
111/// @acp:summary "Parses Python docstrings"
112/// @acp:lock normal
113pub struct DocstringParser {
114    /// Python-specific extensions parsed from docstrings
115    extensions: PythonDocExtensions,
116}
117
118impl DocstringParser {
119    /// @acp:summary "Creates a new docstring parser"
120    pub fn new() -> Self {
121        Self {
122            extensions: PythonDocExtensions::default(),
123        }
124    }
125
126    /// @acp:summary "Gets the parsed Python extensions"
127    pub fn extensions(&self) -> &PythonDocExtensions {
128        &self.extensions
129    }
130
131    /// @acp:summary "Detects the docstring style"
132    pub fn detect_style(raw: &str) -> DocstringStyle {
133        let lines: Vec<&str> = raw.lines().collect();
134
135        // Check each line for style indicators
136        for (i, line) in lines.iter().enumerate() {
137            let trimmed = line.trim();
138
139            // Check for Sphinx tags (line by line)
140            if SPHINX_TAG.is_match(trimmed) {
141                return DocstringStyle::Sphinx;
142            }
143
144            // Check for Google style sections (line by line)
145            if GOOGLE_SECTION.is_match(trimmed) {
146                return DocstringStyle::Google;
147            }
148
149            // Check for NumPy style (sections with underlines)
150            if i + 1 < lines.len() {
151                let next = lines[i + 1].trim();
152                if !trimmed.is_empty() && next.chars().all(|c| c == '-') && next.len() >= 3 {
153                    return DocstringStyle::NumPy;
154                }
155            }
156        }
157
158        DocstringStyle::Plain
159    }
160
161    /// @acp:summary "Parses Google-style docstring"
162    fn parse_google_style(&self, raw: &str) -> ParsedDocumentation {
163        let mut doc = ParsedDocumentation::new();
164        let mut summary_lines = Vec::new();
165        let mut current_section: Option<String> = None;
166        let mut section_content = Vec::new();
167
168        for line in raw.lines() {
169            let trimmed = line.trim();
170
171            // Check for section header
172            if let Some(caps) = GOOGLE_SECTION.captures(trimmed) {
173                // Save previous section
174                self.save_section(&mut doc, current_section.as_deref(), &section_content);
175                section_content.clear();
176
177                current_section = Some(caps.get(1).unwrap().as_str().to_string());
178            } else if current_section.is_some() {
179                section_content.push(line.to_string());
180            } else if !trimmed.is_empty() {
181                summary_lines.push(trimmed.to_string());
182            }
183        }
184
185        // Save last section
186        self.save_section(&mut doc, current_section.as_deref(), &section_content);
187
188        // First non-empty line is summary
189        if !summary_lines.is_empty() {
190            doc.summary = Some(summary_lines[0].clone());
191            doc.description = Some(summary_lines.join(" "));
192        }
193
194        doc
195    }
196
197    /// @acp:summary "Saves section content to parsed documentation"
198    fn save_section(
199        &self,
200        doc: &mut ParsedDocumentation,
201        section: Option<&str>,
202        content: &[String],
203    ) {
204        let section = match section {
205            Some(s) => s,
206            None => return,
207        };
208
209        // Normalize indentation: find minimum leading whitespace and strip it from all lines
210        // This handles Google style (uniform indentation) and NumPy style (varying indentation)
211        let min_indent = content
212            .iter()
213            .filter(|s| !s.trim().is_empty())
214            .map(|s| s.len() - s.trim_start().len())
215            .min()
216            .unwrap_or(0);
217
218        let normalized: Vec<String> = content
219            .iter()
220            .map(|s| {
221                if s.len() >= min_indent {
222                    s[min_indent..].to_string()
223                } else {
224                    s.clone()
225                }
226            })
227            .collect();
228
229        let raw_text = normalized.join("\n");
230        let text = raw_text.trim().to_string();
231
232        if text.is_empty() {
233            return;
234        }
235
236        match section {
237            "Args" | "Arguments" | "Parameters" => {
238                // Parse parameter entries
239                for param in self.parse_params(&text) {
240                    doc.params.push(param);
241                }
242            }
243            "Other Parameters" => {
244                // Parse as additional parameters with custom tag marker
245                for param in self.parse_params(&text) {
246                    doc.custom_tags.push((
247                        "other_param".to_string(),
248                        format!("{}: {}", param.0, param.2.unwrap_or_default()),
249                    ));
250                }
251            }
252            "Keyword Args" | "Keyword Arguments" => {
253                // Parse keyword arguments
254                for param in self.parse_params(&text) {
255                    doc.custom_tags.push((
256                        "kwarg".to_string(),
257                        format!("{}: {}", param.0, param.2.unwrap_or_default()),
258                    ));
259                }
260            }
261            "Returns" | "Return" => {
262                doc.returns = Some((None, Some(text)));
263            }
264            "Yields" | "Yield" => {
265                // Yields indicates a generator function
266                doc.returns = Some((None, Some(format!("Yields: {}", text))));
267                doc.custom_tags
268                    .push(("generator".to_string(), "true".to_string()));
269            }
270            "Receives" => {
271                // For async generators
272                doc.custom_tags.push(("receives".to_string(), text));
273                doc.custom_tags
274                    .push(("async_generator".to_string(), "true".to_string()));
275            }
276            "Raises" | "Exceptions" => {
277                // Handle both Google style "ExcType: description" and
278                // NumPy style where ExcType is on one line and description is indented
279                let mut current_exc: Option<String> = None;
280                let mut current_desc = Vec::new();
281
282                for line in text.lines() {
283                    let trimmed = line.trim();
284                    if trimmed.is_empty() {
285                        continue;
286                    }
287
288                    let is_indented = line.starts_with("    ") || line.starts_with("\t");
289
290                    if !is_indented {
291                        // Save previous exception
292                        if let Some(exc) = current_exc.take() {
293                            let desc = if current_desc.is_empty() {
294                                None
295                            } else {
296                                Some(current_desc.join(" "))
297                            };
298                            doc.throws.push((exc, desc));
299                            current_desc.clear();
300                        }
301
302                        // Check for "ExcType: description" format (Google style)
303                        let parts: Vec<&str> = trimmed.splitn(2, ':').collect();
304                        let exc_type = parts[0].trim().to_string();
305                        if !exc_type.is_empty() {
306                            current_exc = Some(exc_type);
307                            if let Some(desc) = parts.get(1) {
308                                let d = desc.trim();
309                                if !d.is_empty() {
310                                    current_desc.push(d.to_string());
311                                }
312                            }
313                        }
314                    } else if current_exc.is_some() {
315                        // Description continuation
316                        current_desc.push(trimmed.to_string());
317                    }
318                }
319
320                // Save last exception
321                if let Some(exc) = current_exc {
322                    let desc = if current_desc.is_empty() {
323                        None
324                    } else {
325                        Some(current_desc.join(" "))
326                    };
327                    doc.throws.push((exc, desc));
328                }
329            }
330            "Warns" => {
331                // Warnings that may be raised
332                for line in text.lines() {
333                    let line = line.trim();
334                    if !line.is_empty() {
335                        doc.notes.push(format!("May warn: {}", line));
336                    }
337                }
338            }
339            "Note" | "Notes" => {
340                doc.notes.push(text);
341            }
342            "Warning" | "Warnings" => {
343                doc.notes.push(format!("Warning: {}", text));
344            }
345            "Example" | "Examples" => {
346                doc.examples.push(text);
347            }
348            "See Also" | "References" => {
349                for ref_line in text.lines() {
350                    let ref_line = ref_line.trim();
351                    if !ref_line.is_empty() {
352                        doc.see_refs.push(ref_line.to_string());
353                    }
354                }
355            }
356            "Todo" | "Todos" => {
357                doc.todos.push(text);
358            }
359            "Deprecated" => {
360                doc.deprecated = Some(text);
361            }
362            "Attributes" | "Class Attributes" => {
363                // Parse attribute entries similar to params
364                for attr in self.parse_params(&text) {
365                    doc.custom_tags.push((
366                        format!("attr:{}", attr.0),
367                        format!(
368                            "{}: {}",
369                            attr.1.unwrap_or_default(),
370                            attr.2.unwrap_or_default()
371                        ),
372                    ));
373                }
374            }
375            "Methods" => {
376                // Parse method summaries for class docstrings
377                for line in text.lines() {
378                    let line = line.trim();
379                    if !line.is_empty() {
380                        let parts: Vec<&str> = line.splitn(2, ':').collect();
381                        let method_name = parts[0].trim().to_string();
382                        let desc = parts.get(1).map(|s| s.trim().to_string());
383                        if !method_name.is_empty() {
384                            doc.custom_tags.push((
385                                format!("method:{}", method_name),
386                                desc.unwrap_or_default(),
387                            ));
388                        }
389                    }
390                }
391            }
392            "Version" => {
393                doc.custom_tags.push(("version".to_string(), text));
394            }
395            "Since" => {
396                doc.since = Some(text);
397            }
398            _ => {}
399        }
400    }
401
402    /// @acp:summary "Parses parameter entries from text"
403    /// Supports both Google style "name (type): desc" and NumPy style "name : type"
404    fn parse_params(&self, text: &str) -> Vec<(String, Option<String>, Option<String>)> {
405        let mut params = Vec::new();
406        let mut current_name: Option<String> = None;
407        let mut current_type: Option<String> = None;
408        let mut current_desc = Vec::new();
409
410        for line in text.lines() {
411            let trimmed = line.trim();
412            if trimmed.is_empty() {
413                continue;
414            }
415
416            // Check if this is a new parameter (not indented continuation)
417            let is_indented = line.starts_with("    ") || line.starts_with("\t");
418
419            if !is_indented || current_name.is_none() {
420                // Save previous parameter
421                if let Some(name) = current_name.take() {
422                    let desc = if current_desc.is_empty() {
423                        None
424                    } else {
425                        Some(current_desc.join(" "))
426                    };
427                    params.push((name, current_type.take(), desc));
428                    current_desc.clear();
429                }
430
431                // Try Google style first: "name (type): description" or "name: description"
432                if let Some(caps) = GOOGLE_PARAM.captures(trimmed) {
433                    current_name = Some(caps.get(1).unwrap().as_str().to_string());
434                    current_type = caps.get(2).map(|m| m.as_str().to_string());
435                    if let Some(desc) = caps.get(3) {
436                        let d = desc.as_str().trim();
437                        if !d.is_empty() {
438                            current_desc.push(d.to_string());
439                        }
440                    }
441                }
442                // Try NumPy style: "name : type" or "name : type, optional"
443                else if let Some(caps) = NUMPY_PARAM.captures(trimmed) {
444                    current_name = Some(caps.get(1).unwrap().as_str().to_string());
445                    current_type = Some(caps.get(2).unwrap().as_str().trim().to_string());
446                }
447                // Plain name without type
448                else if !trimmed.contains(':') && !trimmed.contains(' ') {
449                    current_name = Some(trimmed.to_string());
450                }
451            } else if current_name.is_some() {
452                // Continuation of description
453                current_desc.push(trimmed.to_string());
454            }
455        }
456
457        // Save last parameter
458        if let Some(name) = current_name {
459            let desc = if current_desc.is_empty() {
460                None
461            } else {
462                Some(current_desc.join(" "))
463            };
464            params.push((name, current_type, desc));
465        }
466
467        params
468    }
469
470    /// @acp:summary "Parses Sphinx-style docstring"
471    fn parse_sphinx_style(&self, raw: &str) -> ParsedDocumentation {
472        let mut doc = ParsedDocumentation::new();
473        let mut summary_lines = Vec::new();
474        let mut found_tag = false;
475
476        // For multiline tag content
477        let mut current_tag: Option<(String, Option<String>)> = None;
478        let mut current_content = String::new();
479
480        for line in raw.lines() {
481            let trimmed = line.trim();
482
483            if let Some(caps) = SPHINX_TAG.captures(trimmed) {
484                // Save previous multiline tag if any
485                if let Some((tag, name)) = current_tag.take() {
486                    self.save_sphinx_tag(&mut doc, &tag, name.as_deref(), &current_content);
487                    current_content.clear();
488                }
489
490                found_tag = true;
491                let tag = caps.get(1).map(|m| m.as_str()).unwrap_or("");
492                let name = caps.get(2).map(|m| m.as_str().trim().to_string());
493                let content = caps.get(3).map(|m| m.as_str().trim().to_string());
494
495                // Check if this tag might have multiline content
496                if let Some(c) = &content {
497                    if c.is_empty() {
498                        // Start collecting multiline content
499                        current_tag = Some((tag.to_string(), name));
500                    } else {
501                        // Single line tag
502                        self.save_sphinx_tag(&mut doc, tag, name.as_deref(), c);
503                    }
504                } else {
505                    // Tag with no content
506                    self.save_sphinx_tag(&mut doc, tag, name.as_deref(), "");
507                }
508            } else if current_tag.is_some() && (line.starts_with("    ") || line.starts_with("\t"))
509            {
510                // Continuation of multiline content
511                current_content.push_str(trimmed);
512                current_content.push('\n');
513            } else if !found_tag && !trimmed.is_empty() {
514                summary_lines.push(trimmed.to_string());
515            }
516        }
517
518        // Save final multiline tag
519        if let Some((tag, name)) = current_tag.take() {
520            self.save_sphinx_tag(&mut doc, &tag, name.as_deref(), &current_content);
521        }
522
523        if !summary_lines.is_empty() {
524            doc.summary = Some(summary_lines[0].clone());
525            doc.description = Some(summary_lines.join(" "));
526        }
527
528        doc
529    }
530
531    /// @acp:summary "Saves a Sphinx-style tag to parsed documentation"
532    fn save_sphinx_tag(
533        &self,
534        doc: &mut ParsedDocumentation,
535        tag: &str,
536        name: Option<&str>,
537        content: &str,
538    ) {
539        let content = content.trim().to_string();
540
541        match tag {
542            "param" => {
543                if let Some(n) = name {
544                    doc.params.push((
545                        n.to_string(),
546                        None,
547                        if content.is_empty() {
548                            None
549                        } else {
550                            Some(content)
551                        },
552                    ));
553                }
554            }
555            "keyword" | "kwarg" | "kwparam" => {
556                if let Some(n) = name {
557                    doc.custom_tags
558                        .push(("kwarg".to_string(), format!("{}: {}", n, content)));
559                }
560            }
561            "type" => {
562                // Update type for matching param
563                if let Some(n) = name {
564                    for param in &mut doc.params {
565                        if param.0 == n {
566                            param.1 = Some(content.clone());
567                            break;
568                        }
569                    }
570                }
571            }
572            "returns" => {
573                doc.returns = Some((
574                    None,
575                    if content.is_empty() {
576                        None
577                    } else {
578                        Some(content)
579                    },
580                ));
581            }
582            "rtype" => {
583                if let Some(ret) = &mut doc.returns {
584                    ret.0 = Some(content);
585                } else {
586                    doc.returns = Some((Some(content), None));
587                }
588            }
589            "raises" | "raise" => {
590                if let Some(exc) = name {
591                    doc.throws.push((
592                        exc.to_string(),
593                        if content.is_empty() {
594                            None
595                        } else {
596                            Some(content)
597                        },
598                    ));
599                }
600            }
601            "deprecated" => {
602                doc.deprecated = Some(if content.is_empty() {
603                    "Deprecated".to_string()
604                } else {
605                    content
606                });
607            }
608            "version" => {
609                doc.custom_tags.push(("version".to_string(), content));
610            }
611            "since" => {
612                doc.since = Some(content);
613            }
614            "seealso" | "see" => {
615                if !content.is_empty() {
616                    doc.see_refs.push(content);
617                }
618            }
619            "note" => {
620                if !content.is_empty() {
621                    doc.notes.push(content);
622                }
623            }
624            "warning" => {
625                if !content.is_empty() {
626                    doc.notes.push(format!("Warning: {}", content));
627                }
628            }
629            "example" => {
630                if !content.is_empty() {
631                    doc.examples.push(content);
632                }
633            }
634            "todo" => {
635                if !content.is_empty() {
636                    doc.todos.push(content);
637                }
638            }
639            "var" | "ivar" | "cvar" => {
640                if let Some(n) = name {
641                    doc.custom_tags.push((format!("{}:{}", tag, n), content));
642                }
643            }
644            "meta" => {
645                if let Some(key) = name {
646                    doc.custom_tags.push((format!("meta:{}", key), content));
647                }
648            }
649            _ => {}
650        }
651    }
652
653    /// @acp:summary "Parses plain-style docstring"
654    fn parse_plain_style(&self, raw: &str) -> ParsedDocumentation {
655        let mut doc = ParsedDocumentation::new();
656        let lines: Vec<&str> = raw
657            .lines()
658            .map(|l| l.trim())
659            .filter(|l| !l.is_empty())
660            .collect();
661
662        if !lines.is_empty() {
663            doc.summary = Some(lines[0].to_string());
664        }
665
666        if lines.len() > 1 {
667            doc.description = Some(lines.join(" "));
668        }
669
670        doc
671    }
672
673    /// @acp:summary "Parses NumPy-style docstring"
674    fn parse_numpy_style(&self, raw: &str) -> ParsedDocumentation {
675        // NumPy style is similar to Google but with underlined headers
676        // For now, treat it similarly
677        let mut doc = ParsedDocumentation::new();
678        let lines: Vec<&str> = raw.lines().collect();
679        let mut summary_lines = Vec::new();
680        let mut current_section: Option<String> = None;
681        let mut section_content = Vec::new();
682        let mut i = 0;
683
684        while i < lines.len() {
685            let line = lines[i].trim();
686
687            // Check if next line is underline (indicates section header)
688            if i + 1 < lines.len() {
689                let next = lines[i + 1].trim();
690                if !line.is_empty() && next.chars().all(|c| c == '-') && next.len() >= 3 {
691                    // Save previous section
692                    self.save_section(&mut doc, current_section.as_deref(), &section_content);
693                    section_content.clear();
694                    current_section = Some(line.to_string());
695                    i += 2; // Skip header and underline
696                    continue;
697                }
698            }
699
700            if current_section.is_some() {
701                section_content.push(lines[i].to_string());
702            } else if !line.is_empty() {
703                summary_lines.push(line.to_string());
704            }
705
706            i += 1;
707        }
708
709        // Save last section
710        self.save_section(&mut doc, current_section.as_deref(), &section_content);
711
712        if !summary_lines.is_empty() {
713            doc.summary = Some(summary_lines[0].clone());
714            doc.description = Some(summary_lines.join(" "));
715        }
716
717        doc
718    }
719}
720
721impl Default for DocstringParser {
722    fn default() -> Self {
723        Self::new()
724    }
725}
726
727impl DocStandardParser for DocstringParser {
728    fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
729        match Self::detect_style(raw_comment) {
730            DocstringStyle::Google => self.parse_google_style(raw_comment),
731            DocstringStyle::NumPy => self.parse_numpy_style(raw_comment),
732            DocstringStyle::Sphinx => self.parse_sphinx_style(raw_comment),
733            DocstringStyle::Plain => self.parse_plain_style(raw_comment),
734        }
735    }
736
737    fn standard_name(&self) -> &'static str {
738        "docstring"
739    }
740
741    /// @acp:summary "Converts parsed docstring to ACP suggestions with Python-specific handling"
742    fn to_suggestions(
743        &self,
744        parsed: &ParsedDocumentation,
745        target: &str,
746        line: usize,
747    ) -> Vec<Suggestion> {
748        let mut suggestions = Vec::new();
749
750        // Convert summary (truncated)
751        if let Some(summary) = &parsed.summary {
752            let truncated = truncate_for_summary(summary, 100);
753            suggestions.push(Suggestion::summary(
754                target,
755                line,
756                truncated,
757                SuggestionSource::Converted,
758            ));
759        }
760
761        // Convert deprecated
762        if let Some(msg) = &parsed.deprecated {
763            suggestions.push(Suggestion::deprecated(
764                target,
765                line,
766                msg,
767                SuggestionSource::Converted,
768            ));
769        }
770
771        // Convert @see references to @acp:ref
772        for see_ref in &parsed.see_refs {
773            suggestions.push(Suggestion::new(
774                target,
775                line,
776                AnnotationType::Ref,
777                see_ref,
778                SuggestionSource::Converted,
779            ));
780        }
781
782        // Convert @todo to @acp:hack
783        for todo in &parsed.todos {
784            suggestions.push(Suggestion::new(
785                target,
786                line,
787                AnnotationType::Hack,
788                format!("reason=\"{}\"", todo),
789                SuggestionSource::Converted,
790            ));
791        }
792
793        // Convert throws to AI hint
794        if !parsed.throws.is_empty() {
795            let throws_list: Vec<String> = parsed.throws.iter().map(|(t, _)| t.clone()).collect();
796            suggestions.push(Suggestion::ai_hint(
797                target,
798                line,
799                format!("raises {}", throws_list.join(", ")),
800                SuggestionSource::Converted,
801            ));
802        }
803
804        // Convert examples existence to AI hint
805        if !parsed.examples.is_empty() {
806            suggestions.push(Suggestion::ai_hint(
807                target,
808                line,
809                "has examples",
810                SuggestionSource::Converted,
811            ));
812        }
813
814        // Convert notes/warnings to AI hints
815        for note in &parsed.notes {
816            suggestions.push(Suggestion::ai_hint(
817                target,
818                line,
819                note,
820                SuggestionSource::Converted,
821            ));
822        }
823
824        // Python-specific: Convert generator hints from custom tags
825        if parsed
826            .custom_tags
827            .iter()
828            .any(|(k, v)| k == "generator" && v == "true")
829        {
830            suggestions.push(Suggestion::ai_hint(
831                target,
832                line,
833                "generator function (uses yield)",
834                SuggestionSource::Converted,
835            ));
836        }
837
838        // Python-specific: Convert async generator hints
839        if parsed
840            .custom_tags
841            .iter()
842            .any(|(k, v)| k == "async_generator" && v == "true")
843        {
844            suggestions.push(Suggestion::ai_hint(
845                target,
846                line,
847                "async generator (uses yield and receives)",
848                SuggestionSource::Converted,
849            ));
850        }
851
852        // Python-specific: Convert version info
853        if let Some((_, version)) = parsed.custom_tags.iter().find(|(k, _)| k == "version") {
854            suggestions.push(Suggestion::ai_hint(
855                target,
856                line,
857                format!("version: {}", version),
858                SuggestionSource::Converted,
859            ));
860        }
861
862        // Python-specific: Convert since version
863        if let Some(since) = &parsed.since {
864            suggestions.push(Suggestion::ai_hint(
865                target,
866                line,
867                format!("since: {}", since),
868                SuggestionSource::Converted,
869            ));
870        }
871
872        // Python-specific: Convert keyword arguments summary
873        let kwargs: Vec<_> = parsed
874            .custom_tags
875            .iter()
876            .filter(|(k, _)| k == "kwarg")
877            .collect();
878        if !kwargs.is_empty() {
879            let kwarg_names: Vec<_> = kwargs
880                .iter()
881                .filter_map(|(_, v)| v.split(':').next())
882                .map(|s| s.trim())
883                .collect();
884            suggestions.push(Suggestion::ai_hint(
885                target,
886                line,
887                format!("accepts kwargs: {}", kwarg_names.join(", ")),
888                SuggestionSource::Converted,
889            ));
890        }
891
892        // Python-specific: Convert class attributes summary
893        let attrs: Vec<_> = parsed
894            .custom_tags
895            .iter()
896            .filter(|(k, _)| k.starts_with("attr:"))
897            .collect();
898        if !attrs.is_empty() {
899            let attr_names: Vec<_> = attrs
900                .iter()
901                .map(|(k, _)| k.strip_prefix("attr:").unwrap_or(k))
902                .collect();
903            suggestions.push(Suggestion::ai_hint(
904                target,
905                line,
906                format!("attributes: {}", attr_names.join(", ")),
907                SuggestionSource::Converted,
908            ));
909        }
910
911        // Python-specific: Convert methods summary (for class docstrings)
912        let methods: Vec<_> = parsed
913            .custom_tags
914            .iter()
915            .filter(|(k, _)| k.starts_with("method:"))
916            .collect();
917        if !methods.is_empty() {
918            let method_names: Vec<_> = methods
919                .iter()
920                .map(|(k, _)| k.strip_prefix("method:").unwrap_or(k))
921                .collect();
922            suggestions.push(Suggestion::ai_hint(
923                target,
924                line,
925                format!("methods: {}", method_names.join(", ")),
926                SuggestionSource::Converted,
927            ));
928        }
929
930        // Python-specific: Convert instance variables
931        let ivars: Vec<_> = parsed
932            .custom_tags
933            .iter()
934            .filter(|(k, _)| k.starts_with("ivar:"))
935            .collect();
936        if !ivars.is_empty() {
937            let ivar_names: Vec<_> = ivars
938                .iter()
939                .map(|(k, _)| k.strip_prefix("ivar:").unwrap_or(k))
940                .collect();
941            suggestions.push(Suggestion::ai_hint(
942                target,
943                line,
944                format!("instance vars: {}", ivar_names.join(", ")),
945                SuggestionSource::Converted,
946            ));
947        }
948
949        // Python-specific: Convert class variables
950        let cvars: Vec<_> = parsed
951            .custom_tags
952            .iter()
953            .filter(|(k, _)| k.starts_with("cvar:"))
954            .collect();
955        if !cvars.is_empty() {
956            let cvar_names: Vec<_> = cvars
957                .iter()
958                .map(|(k, _)| k.strip_prefix("cvar:").unwrap_or(k))
959                .collect();
960            suggestions.push(Suggestion::ai_hint(
961                target,
962                line,
963                format!("class vars: {}", cvar_names.join(", ")),
964                SuggestionSource::Converted,
965            ));
966        }
967
968        // Python-specific: Convert meta tags
969        let metas: Vec<_> = parsed
970            .custom_tags
971            .iter()
972            .filter(|(k, _)| k.starts_with("meta:"))
973            .collect();
974        for (key, value) in metas {
975            let meta_key = key.strip_prefix("meta:").unwrap_or(key);
976            suggestions.push(Suggestion::ai_hint(
977                target,
978                line,
979                format!("{}: {}", meta_key, value),
980                SuggestionSource::Converted,
981            ));
982        }
983
984        suggestions
985    }
986}
987
988/// @acp:summary "Truncates a string to the specified length for summary use"
989fn truncate_for_summary(s: &str, max_len: usize) -> String {
990    let trimmed = s.trim();
991    if trimmed.len() <= max_len {
992        trimmed.to_string()
993    } else {
994        let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
995        format!("{}...", &trimmed[..truncate_at])
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn test_detect_style_google() {
1005        let google = "Summary line.\n\nArgs:\n    x: description";
1006        assert_eq!(
1007            DocstringParser::detect_style(google),
1008            DocstringStyle::Google
1009        );
1010    }
1011
1012    #[test]
1013    fn test_detect_style_sphinx() {
1014        let sphinx = "Summary line.\n\n:param x: description";
1015        assert_eq!(
1016            DocstringParser::detect_style(sphinx),
1017            DocstringStyle::Sphinx
1018        );
1019    }
1020
1021    #[test]
1022    fn test_detect_style_numpy() {
1023        let numpy = "Summary line.\n\nParameters\n----------\nx : int";
1024        assert_eq!(DocstringParser::detect_style(numpy), DocstringStyle::NumPy);
1025    }
1026
1027    #[test]
1028    fn test_detect_style_plain() {
1029        let plain = "Just a simple docstring.";
1030        assert_eq!(DocstringParser::detect_style(plain), DocstringStyle::Plain);
1031    }
1032
1033    #[test]
1034    fn test_parse_google_style() {
1035        let parser = DocstringParser::new();
1036        let doc = parser.parse(
1037            r#"
1038Process a payment transaction.
1039
1040Args:
1041    amount: The payment amount
1042    currency: The currency code (default: USD)
1043
1044Returns:
1045    PaymentResult with transaction ID
1046
1047Raises:
1048    PaymentError: If payment fails
1049"#,
1050        );
1051
1052        assert_eq!(
1053            doc.summary,
1054            Some("Process a payment transaction.".to_string())
1055        );
1056        assert_eq!(doc.params.len(), 2);
1057        assert_eq!(doc.params[0].0, "amount");
1058        assert!(doc.returns.is_some());
1059        assert_eq!(doc.throws.len(), 1);
1060        assert_eq!(doc.throws[0].0, "PaymentError");
1061    }
1062
1063    #[test]
1064    fn test_parse_sphinx_style() {
1065        let parser = DocstringParser::new();
1066        let doc = parser.parse(
1067            r#"
1068Process a payment.
1069
1070:param amount: The payment amount
1071:type amount: float
1072:returns: The result
1073:raises PaymentError: If payment fails
1074:deprecated: Use process_payment_v2 instead
1075"#,
1076        );
1077
1078        assert_eq!(doc.summary, Some("Process a payment.".to_string()));
1079        assert_eq!(doc.params.len(), 1);
1080        assert_eq!(doc.params[0].0, "amount");
1081        assert!(doc.returns.is_some());
1082        assert_eq!(doc.throws.len(), 1);
1083        assert!(doc.deprecated.is_some());
1084    }
1085
1086    #[test]
1087    fn test_parse_plain_style() {
1088        let parser = DocstringParser::new();
1089        let doc = parser.parse("Simple summary.\n\nMore details here.");
1090
1091        assert_eq!(doc.summary, Some("Simple summary.".to_string()));
1092        assert!(doc.description.unwrap().contains("More details"));
1093    }
1094
1095    #[test]
1096    fn test_parse_deprecated_section() {
1097        let parser = DocstringParser::new();
1098        let doc = parser.parse(
1099            r#"
1100Old function.
1101
1102Deprecated:
1103    Use new_function instead.
1104"#,
1105        );
1106
1107        assert!(doc.deprecated.is_some());
1108        assert!(doc.deprecated.unwrap().contains("new_function"));
1109    }
1110
1111    #[test]
1112    fn test_parse_notes_and_warnings() {
1113        let parser = DocstringParser::new();
1114        let doc = parser.parse(
1115            r#"
1116Summary.
1117
1118Note:
1119    Important note here.
1120
1121Warning:
1122    Be careful with this.
1123"#,
1124        );
1125
1126        assert_eq!(doc.notes.len(), 2);
1127    }
1128
1129    // ========================================
1130    // Sprint 3: Python-specific feature tests
1131    // ========================================
1132
1133    #[test]
1134    fn test_parse_google_yields_generator() {
1135        let parser = DocstringParser::new();
1136        let doc = parser.parse(
1137            r#"
1138Generator function that yields items.
1139
1140Yields:
1141    int: The next item in the sequence
1142"#,
1143        );
1144
1145        assert!(doc.returns.is_some());
1146        let (_, desc) = doc.returns.as_ref().unwrap();
1147        assert!(desc.as_ref().unwrap().contains("Yields"));
1148
1149        // Check generator flag in custom_tags
1150        assert!(doc
1151            .custom_tags
1152            .iter()
1153            .any(|(k, v)| k == "generator" && v == "true"));
1154    }
1155
1156    #[test]
1157    fn test_parse_google_receives_async_generator() {
1158        let parser = DocstringParser::new();
1159        let doc = parser.parse(
1160            r#"
1161Async generator that receives values.
1162
1163Yields:
1164    str: Generated value
1165
1166Receives:
1167    int: Value sent to generator
1168"#,
1169        );
1170
1171        // Check async_generator flag
1172        assert!(doc
1173            .custom_tags
1174            .iter()
1175            .any(|(k, v)| k == "async_generator" && v == "true"));
1176        assert!(doc
1177            .custom_tags
1178            .iter()
1179            .any(|(k, v)| k == "receives" && !v.is_empty()));
1180    }
1181
1182    #[test]
1183    fn test_parse_google_keyword_args() {
1184        let parser = DocstringParser::new();
1185        let doc = parser.parse(
1186            r#"
1187Function with keyword arguments.
1188
1189Args:
1190    x: Required argument
1191
1192Keyword Args:
1193    timeout: Connection timeout
1194    retries: Number of retries
1195"#,
1196        );
1197
1198        assert_eq!(doc.params.len(), 1);
1199        assert_eq!(doc.params[0].0, "x");
1200
1201        // Check kwargs in custom_tags
1202        let kwargs: Vec<_> = doc
1203            .custom_tags
1204            .iter()
1205            .filter(|(k, _)| k == "kwarg")
1206            .collect();
1207        assert_eq!(kwargs.len(), 2);
1208    }
1209
1210    #[test]
1211    fn test_parse_google_other_parameters() {
1212        let parser = DocstringParser::new();
1213        let doc = parser.parse(
1214            r#"
1215Function with other parameters.
1216
1217Args:
1218    x: Main argument
1219
1220Other Parameters:
1221    debug: Enable debug mode
1222    verbose: Verbosity level
1223"#,
1224        );
1225
1226        assert_eq!(doc.params.len(), 1);
1227
1228        let other_params: Vec<_> = doc
1229            .custom_tags
1230            .iter()
1231            .filter(|(k, _)| k == "other_param")
1232            .collect();
1233        assert_eq!(other_params.len(), 2);
1234    }
1235
1236    #[test]
1237    fn test_parse_google_attributes() {
1238        let parser = DocstringParser::new();
1239        let doc = parser.parse(
1240            r#"
1241A class that does something.
1242
1243Attributes:
1244    name (str): The name
1245    value (int): The value
1246"#,
1247        );
1248
1249        let attrs: Vec<_> = doc
1250            .custom_tags
1251            .iter()
1252            .filter(|(k, _)| k.starts_with("attr:"))
1253            .collect();
1254        assert_eq!(attrs.len(), 2);
1255    }
1256
1257    #[test]
1258    fn test_parse_google_methods() {
1259        let parser = DocstringParser::new();
1260        let doc = parser.parse(
1261            r#"
1262A utility class.
1263
1264Methods:
1265    process: Processes the input
1266    validate: Validates the data
1267    cleanup: Cleans up resources
1268"#,
1269        );
1270
1271        let methods: Vec<_> = doc
1272            .custom_tags
1273            .iter()
1274            .filter(|(k, _)| k.starts_with("method:"))
1275            .collect();
1276        assert_eq!(methods.len(), 3);
1277    }
1278
1279    #[test]
1280    fn test_parse_google_warns() {
1281        let parser = DocstringParser::new();
1282        let doc = parser.parse(
1283            r#"
1284Function that may emit warnings.
1285
1286Warns:
1287    DeprecationWarning: If using old API
1288    UserWarning: If input is unusual
1289"#,
1290        );
1291
1292        // Warns become notes
1293        assert!(doc.notes.len() >= 2);
1294        assert!(doc.notes.iter().any(|n| n.contains("DeprecationWarning")));
1295    }
1296
1297    #[test]
1298    fn test_parse_google_version_since() {
1299        let parser = DocstringParser::new();
1300        let doc = parser.parse(
1301            r#"
1302New feature function.
1303
1304Version:
1305    1.2.0
1306
1307Since:
1308    2023-01-15
1309"#,
1310        );
1311
1312        assert!(doc
1313            .custom_tags
1314            .iter()
1315            .any(|(k, v)| k == "version" && v == "1.2.0"));
1316        assert_eq!(doc.since, Some("2023-01-15".to_string()));
1317    }
1318
1319    #[test]
1320    fn test_parse_sphinx_version_since() {
1321        let parser = DocstringParser::new();
1322        let doc = parser.parse(
1323            r#"
1324New feature.
1325
1326:version: 2.0.0
1327:since: 1.5.0
1328"#,
1329        );
1330
1331        assert!(doc
1332            .custom_tags
1333            .iter()
1334            .any(|(k, v)| k == "version" && v == "2.0.0"));
1335        assert_eq!(doc.since, Some("1.5.0".to_string()));
1336    }
1337
1338    #[test]
1339    fn test_parse_sphinx_seealso_note_warning() {
1340        let parser = DocstringParser::new();
1341        let doc = parser.parse(
1342            r#"
1343Function summary.
1344
1345:seealso: other_function
1346:note: Important note
1347:warning: Be careful
1348"#,
1349        );
1350
1351        assert_eq!(doc.see_refs.len(), 1);
1352        assert_eq!(doc.see_refs[0], "other_function");
1353        assert_eq!(doc.notes.len(), 2);
1354    }
1355
1356    #[test]
1357    fn test_parse_sphinx_example_todo() {
1358        let parser = DocstringParser::new();
1359        let doc = parser.parse(
1360            r#"
1361Function summary.
1362
1363:example: result = my_func(1, 2)
1364:todo: Add more examples
1365"#,
1366        );
1367
1368        assert_eq!(doc.examples.len(), 1);
1369        assert!(doc.examples[0].contains("my_func"));
1370        assert_eq!(doc.todos.len(), 1);
1371    }
1372
1373    #[test]
1374    fn test_parse_sphinx_var_ivar_cvar() {
1375        let parser = DocstringParser::new();
1376        let doc = parser.parse(
1377            r#"
1378Class summary.
1379
1380:ivar name: Instance variable
1381:cvar count: Class variable
1382:var value: Generic variable
1383"#,
1384        );
1385
1386        assert!(doc.custom_tags.iter().any(|(k, _)| k == "ivar:name"));
1387        assert!(doc.custom_tags.iter().any(|(k, _)| k == "cvar:count"));
1388        assert!(doc.custom_tags.iter().any(|(k, _)| k == "var:value"));
1389    }
1390
1391    #[test]
1392    fn test_parse_sphinx_meta() {
1393        let parser = DocstringParser::new();
1394        let doc = parser.parse(
1395            r#"
1396Function summary.
1397
1398:meta author: John Doe
1399:meta license: MIT
1400"#,
1401        );
1402
1403        assert!(doc
1404            .custom_tags
1405            .iter()
1406            .any(|(k, v)| k == "meta:author" && v == "John Doe"));
1407        assert!(doc
1408            .custom_tags
1409            .iter()
1410            .any(|(k, v)| k == "meta:license" && v == "MIT"));
1411    }
1412
1413    #[test]
1414    fn test_parse_sphinx_keyword_args() {
1415        let parser = DocstringParser::new();
1416        let doc = parser.parse(
1417            r#"
1418Function with kwargs.
1419
1420:param x: Regular param
1421:keyword timeout: Timeout in seconds
1422:kwarg retries: Number of retries
1423"#,
1424        );
1425
1426        assert_eq!(doc.params.len(), 1);
1427
1428        let kwargs: Vec<_> = doc
1429            .custom_tags
1430            .iter()
1431            .filter(|(k, _)| k == "kwarg")
1432            .collect();
1433        assert_eq!(kwargs.len(), 2);
1434    }
1435
1436    #[test]
1437    fn test_parse_numpy_comprehensive() {
1438        let parser = DocstringParser::new();
1439        let doc = parser.parse(
1440            r#"
1441Calculate the distance between two points.
1442
1443A longer description that spans
1444multiple lines.
1445
1446Parameters
1447----------
1448x1 : float
1449    First x coordinate
1450y1 : float
1451    First y coordinate
1452
1453Returns
1454-------
1455float
1456    The Euclidean distance
1457
1458Raises
1459------
1460ValueError
1461    If coordinates are invalid
1462
1463See Also
1464--------
1465calculate_angle : Calculates the angle between points
1466
1467Examples
1468--------
1469>>> distance(0, 0, 3, 4)
14705.0
1471"#,
1472        );
1473
1474        assert_eq!(
1475            doc.summary,
1476            Some("Calculate the distance between two points.".to_string())
1477        );
1478        assert_eq!(doc.params.len(), 2);
1479        assert!(doc.returns.is_some());
1480        assert_eq!(doc.throws.len(), 1);
1481        assert!(!doc.see_refs.is_empty());
1482        assert!(!doc.examples.is_empty());
1483    }
1484
1485    #[test]
1486    fn test_parse_numpy_yields() {
1487        let parser = DocstringParser::new();
1488        let doc = parser.parse(
1489            r#"
1490Generate items.
1491
1492Yields
1493------
1494int
1495    The next number
1496"#,
1497        );
1498
1499        assert!(doc
1500            .custom_tags
1501            .iter()
1502            .any(|(k, v)| k == "generator" && v == "true"));
1503    }
1504
1505    #[test]
1506    fn test_parse_numpy_attributes() {
1507        let parser = DocstringParser::new();
1508        let doc = parser.parse(
1509            r#"
1510A data container class.
1511
1512Attributes
1513----------
1514data : array-like
1515    The stored data
1516shape : tuple
1517    Shape of the data
1518"#,
1519        );
1520
1521        let attrs: Vec<_> = doc
1522            .custom_tags
1523            .iter()
1524            .filter(|(k, _)| k.starts_with("attr:"))
1525            .collect();
1526        assert_eq!(attrs.len(), 2);
1527    }
1528
1529    // ========================================
1530    // to_suggestions conversion tests
1531    // ========================================
1532
1533    #[test]
1534    fn test_to_suggestions_basic() {
1535        let parser = DocstringParser::new();
1536        let doc = parser.parse(
1537            r#"
1538Process data efficiently.
1539
1540Args:
1541    data: Input data
1542
1543Returns:
1544    Processed result
1545"#,
1546        );
1547
1548        let suggestions = parser.to_suggestions(&doc, "process", 10);
1549
1550        // Should have summary
1551        assert!(suggestions
1552            .iter()
1553            .any(|s| s.annotation_type == AnnotationType::Summary
1554                && s.value.contains("Process data")));
1555    }
1556
1557    #[test]
1558    fn test_to_suggestions_deprecated() {
1559        let parser = DocstringParser::new();
1560        let doc = parser.parse(
1561            r#"
1562Old function.
1563
1564Deprecated:
1565    Use new_function instead
1566"#,
1567        );
1568
1569        let suggestions = parser.to_suggestions(&doc, "old_func", 5);
1570
1571        assert!(suggestions
1572            .iter()
1573            .any(|s| s.annotation_type == AnnotationType::Deprecated));
1574    }
1575
1576    #[test]
1577    fn test_to_suggestions_raises() {
1578        let parser = DocstringParser::new();
1579        let doc = parser.parse(
1580            r#"
1581May raise errors.
1582
1583Raises:
1584    ValueError: Bad value
1585    TypeError: Wrong type
1586"#,
1587        );
1588
1589        let suggestions = parser.to_suggestions(&doc, "risky", 1);
1590
1591        assert!(suggestions
1592            .iter()
1593            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("raises")));
1594    }
1595
1596    #[test]
1597    fn test_to_suggestions_generator() {
1598        let parser = DocstringParser::new();
1599        let doc = parser.parse(
1600            r#"
1601Generate numbers.
1602
1603Yields:
1604    int: Next number
1605"#,
1606        );
1607
1608        let suggestions = parser.to_suggestions(&doc, "gen", 1);
1609
1610        assert!(suggestions
1611            .iter()
1612            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("generator")));
1613    }
1614
1615    #[test]
1616    fn test_to_suggestions_version_since() {
1617        let parser = DocstringParser::new();
1618        let doc = parser.parse(
1619            r#"
1620New feature.
1621
1622:version: 1.0.0
1623:since: 0.9.0
1624"#,
1625        );
1626
1627        let suggestions = parser.to_suggestions(&doc, "feature", 1);
1628
1629        assert!(suggestions
1630            .iter()
1631            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("version:")));
1632        assert!(suggestions
1633            .iter()
1634            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("since:")));
1635    }
1636
1637    #[test]
1638    fn test_to_suggestions_kwargs() {
1639        let parser = DocstringParser::new();
1640        let doc = parser.parse(
1641            r#"
1642Function with kwargs.
1643
1644Keyword Args:
1645    timeout: Timeout value
1646    retries: Retry count
1647"#,
1648        );
1649
1650        let suggestions = parser.to_suggestions(&doc, "func", 1);
1651
1652        assert!(suggestions
1653            .iter()
1654            .any(|s| s.annotation_type == AnnotationType::AiHint
1655                && s.value.contains("accepts kwargs")));
1656    }
1657
1658    #[test]
1659    fn test_to_suggestions_attributes() {
1660        let parser = DocstringParser::new();
1661        let doc = parser.parse(
1662            r#"
1663Data class.
1664
1665Attributes:
1666    name: The name
1667    value: The value
1668"#,
1669        );
1670
1671        let suggestions = parser.to_suggestions(&doc, "DataClass", 1);
1672
1673        assert!(suggestions.iter().any(
1674            |s| s.annotation_type == AnnotationType::AiHint && s.value.contains("attributes:")
1675        ));
1676    }
1677
1678    #[test]
1679    fn test_to_suggestions_methods() {
1680        let parser = DocstringParser::new();
1681        let doc = parser.parse(
1682            r#"
1683Utility class.
1684
1685Methods:
1686    process: Process data
1687    validate: Validate input
1688"#,
1689        );
1690
1691        let suggestions = parser.to_suggestions(&doc, "UtilClass", 1);
1692
1693        assert!(suggestions
1694            .iter()
1695            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("methods:")));
1696    }
1697
1698    #[test]
1699    fn test_to_suggestions_instance_class_vars() {
1700        let parser = DocstringParser::new();
1701        let doc = parser.parse(
1702            r#"
1703Class with variables.
1704
1705:ivar name: Instance variable
1706:cvar count: Class variable
1707"#,
1708        );
1709
1710        let suggestions = parser.to_suggestions(&doc, "MyClass", 1);
1711
1712        assert!(suggestions
1713            .iter()
1714            .any(|s| s.annotation_type == AnnotationType::AiHint
1715                && s.value.contains("instance vars:")));
1716        assert!(suggestions.iter().any(
1717            |s| s.annotation_type == AnnotationType::AiHint && s.value.contains("class vars:")
1718        ));
1719    }
1720
1721    #[test]
1722    fn test_to_suggestions_meta() {
1723        let parser = DocstringParser::new();
1724        let doc = parser.parse(
1725            r#"
1726Function with meta.
1727
1728:meta author: Jane Doe
1729"#,
1730        );
1731
1732        let suggestions = parser.to_suggestions(&doc, "func", 1);
1733
1734        assert!(suggestions
1735            .iter()
1736            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("author:")));
1737    }
1738
1739    #[test]
1740    fn test_to_suggestions_see_refs() {
1741        let parser = DocstringParser::new();
1742        let doc = parser.parse(
1743            r#"
1744Function with references.
1745
1746See Also:
1747    other_function
1748    related_module
1749"#,
1750        );
1751
1752        let suggestions = parser.to_suggestions(&doc, "func", 1);
1753
1754        assert!(suggestions
1755            .iter()
1756            .any(|s| s.annotation_type == AnnotationType::Ref));
1757    }
1758
1759    #[test]
1760    fn test_to_suggestions_todos() {
1761        let parser = DocstringParser::new();
1762        let doc = parser.parse(
1763            r#"
1764Work in progress.
1765
1766Todo:
1767    Finish implementation
1768"#,
1769        );
1770
1771        let suggestions = parser.to_suggestions(&doc, "func", 1);
1772
1773        assert!(suggestions
1774            .iter()
1775            .any(|s| s.annotation_type == AnnotationType::Hack));
1776    }
1777
1778    #[test]
1779    fn test_truncate_for_summary() {
1780        assert_eq!(truncate_for_summary("Short", 100), "Short");
1781        assert_eq!(
1782            truncate_for_summary("This is a very long summary that needs truncation", 20),
1783            "This is a very long..."
1784        );
1785    }
1786
1787    #[test]
1788    fn test_multiline_sphinx_content() {
1789        let parser = DocstringParser::new();
1790        let doc = parser.parse(
1791            r#"
1792Function summary.
1793
1794:note: This is a note that spans
1795    multiple lines and should be
1796    combined into one.
1797:param x: A parameter
1798"#,
1799        );
1800
1801        assert!(!doc.notes.is_empty());
1802        assert_eq!(doc.params.len(), 1);
1803    }
1804}