1use std::sync::LazyLock;
31
32use regex::Regex;
33
34use super::{DocStandardParser, ParsedDocumentation};
35use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
36
37static 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
44static 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
51static GOOGLE_PARAM: LazyLock<Regex> = LazyLock::new(|| {
53 Regex::new(r"^\s*(\w+)(?:\s*\(([^)]+)\))?:\s*(.*)$").expect("Invalid Google param regex")
54});
55
56static NUMPY_PARAM: LazyLock<Regex> = LazyLock::new(|| {
58 Regex::new(r"^(\w+)\s*:\s*([^,\n]+)(?:,\s*optional)?$").expect("Invalid NumPy param regex")
59});
60
61#[derive(Debug, Clone, Default)]
63pub struct PythonDocExtensions {
64 pub is_generator: bool,
66
67 pub is_async_generator: bool,
69
70 pub class_attributes: Vec<(String, Option<String>, Option<String>)>,
72
73 pub instance_vars: Vec<(String, Option<String>, Option<String>)>,
75
76 pub class_vars: Vec<(String, Option<String>, Option<String>)>,
78
79 pub methods: Vec<(String, Option<String>)>,
81
82 pub kwargs: Vec<(String, Option<String>, Option<String>)>,
84
85 pub version: Option<String>,
87
88 pub since: Option<String>,
90
91 pub warnings: Vec<String>,
93
94 pub meta: Vec<(String, String)>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DocstringStyle {
101 Google,
103 NumPy,
105 Sphinx,
107 Plain,
109}
110
111pub struct DocstringParser {
114 extensions: PythonDocExtensions,
116}
117
118impl DocstringParser {
119 pub fn new() -> Self {
121 Self {
122 extensions: PythonDocExtensions::default(),
123 }
124 }
125
126 pub fn extensions(&self) -> &PythonDocExtensions {
128 &self.extensions
129 }
130
131 pub fn detect_style(raw: &str) -> DocstringStyle {
133 let lines: Vec<&str> = raw.lines().collect();
134
135 for (i, line) in lines.iter().enumerate() {
137 let trimmed = line.trim();
138
139 if SPHINX_TAG.is_match(trimmed) {
141 return DocstringStyle::Sphinx;
142 }
143
144 if GOOGLE_SECTION.is_match(trimmed) {
146 return DocstringStyle::Google;
147 }
148
149 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 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 if let Some(caps) = GOOGLE_SECTION.captures(trimmed) {
173 self.save_section(&mut doc, current_section.as_deref(), §ion_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 self.save_section(&mut doc, current_section.as_deref(), §ion_content);
187
188 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 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 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 for param in self.parse_params(&text) {
240 doc.params.push(param);
241 }
242 }
243 "Other Parameters" => {
244 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 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 doc.returns = Some((None, Some(format!("Yields: {}", text))));
267 doc.custom_tags
268 .push(("generator".to_string(), "true".to_string()));
269 }
270 "Receives" => {
271 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 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 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 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 current_desc.push(trimmed.to_string());
317 }
318 }
319
320 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 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 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 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 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 let is_indented = line.starts_with(" ") || line.starts_with("\t");
418
419 if !is_indented || current_name.is_none() {
420 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 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 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 else if !trimmed.contains(':') && !trimmed.contains(' ') {
449 current_name = Some(trimmed.to_string());
450 }
451 } else if current_name.is_some() {
452 current_desc.push(trimmed.to_string());
454 }
455 }
456
457 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 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 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 if let Some((tag, name)) = current_tag.take() {
486 self.save_sphinx_tag(&mut doc, &tag, name.as_deref(), ¤t_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 if let Some(c) = &content {
497 if c.is_empty() {
498 current_tag = Some((tag.to_string(), name));
500 } else {
501 self.save_sphinx_tag(&mut doc, tag, name.as_deref(), c);
503 }
504 } else {
505 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 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 if let Some((tag, name)) = current_tag.take() {
520 self.save_sphinx_tag(&mut doc, &tag, name.as_deref(), ¤t_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 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 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 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 fn parse_numpy_style(&self, raw: &str) -> ParsedDocumentation {
675 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 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 self.save_section(&mut doc, current_section.as_deref(), §ion_content);
693 section_content.clear();
694 current_section = Some(line.to_string());
695 i += 2; 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 self.save_section(&mut doc, current_section.as_deref(), §ion_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 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 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 if let Some(msg) = &parsed.deprecated {
763 suggestions.push(Suggestion::deprecated(
764 target,
765 line,
766 msg,
767 SuggestionSource::Converted,
768 ));
769 }
770
771 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 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 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 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 for note in &parsed.notes {
816 suggestions.push(Suggestion::ai_hint(
817 target,
818 line,
819 note,
820 SuggestionSource::Converted,
821 ));
822 }
823
824 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 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 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 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 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 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 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 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 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 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
988fn 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 #[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 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 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 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 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 #[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 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}