acp/annotate/converters/
mod.rs

1//! @acp:module "Documentation Converters"
2//! @acp:summary "Converts existing documentation standards to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Documentation Converters
8//!
9//! Provides parsers and converters for various documentation standards:
10//! - JSDoc/TSDoc (JavaScript/TypeScript)
11//! - Python docstrings (Google, NumPy, Sphinx, plain)
12//! - Rust doc comments
13//! - Go doc comments
14//! - Javadoc (Java)
15//!
16//! Each converter parses the raw documentation format into a structured
17//! [`ParsedDocumentation`] and then converts it to ACP [`Suggestion`]s.
18
19pub mod docstring;
20pub mod godoc;
21pub mod javadoc;
22pub mod jsdoc;
23pub mod rustdoc;
24
25pub use docstring::DocstringParser;
26pub use godoc::{GoDocExtensions, GodocParser};
27pub use javadoc::{JavadocExtensions, JavadocParser};
28pub use jsdoc::{JsDocParser, TsDocExtensions, TsDocParser};
29pub use rustdoc::{RustDocExtensions, RustdocParser};
30
31use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
32
33/// @acp:summary "Parsed documentation from an existing standard"
34/// Contains structured information extracted from any documentation format.
35#[derive(Debug, Clone, Default)]
36pub struct ParsedDocumentation {
37    /// First line or @description content
38    pub summary: Option<String>,
39
40    /// Full description text
41    pub description: Option<String>,
42
43    /// Deprecation notice
44    pub deprecated: Option<String>,
45
46    /// @see/@link references
47    pub see_refs: Vec<String>,
48
49    /// @todo/@fixme items
50    pub todos: Vec<String>,
51
52    /// @param entries: (name, type, description)
53    pub params: Vec<(String, Option<String>, Option<String>)>,
54
55    /// @returns/@return entry: (type, description)
56    pub returns: Option<(Option<String>, Option<String>)>,
57
58    /// @throws/@raises/@exception entries: (type, description)
59    pub throws: Vec<(String, Option<String>)>,
60
61    /// @example blocks
62    pub examples: Vec<String>,
63
64    /// @since version
65    pub since: Option<String>,
66
67    /// @author
68    pub author: Option<String>,
69
70    /// Custom tags: (tag_name, value)
71    pub custom_tags: Vec<(String, String)>,
72
73    /// Any warnings or notes
74    pub notes: Vec<String>,
75}
76
77impl ParsedDocumentation {
78    /// @acp:summary "Creates an empty parsed documentation"
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// @acp:summary "Checks if this documentation has any content"
84    pub fn is_empty(&self) -> bool {
85        self.summary.is_none()
86            && self.description.is_none()
87            && self.deprecated.is_none()
88            && self.see_refs.is_empty()
89            && self.todos.is_empty()
90            && self.params.is_empty()
91            && self.returns.is_none()
92            && self.throws.is_empty()
93            && self.examples.is_empty()
94            && self.notes.is_empty()
95            && self.custom_tags.is_empty()
96    }
97
98    /// @acp:summary "Gets the visibility modifier from custom tags"
99    pub fn get_visibility(&self) -> Option<&str> {
100        self.custom_tags
101            .iter()
102            .find(|(k, _)| k == "visibility")
103            .map(|(_, v)| v.as_str())
104    }
105
106    /// @acp:summary "Gets the module name from custom tags"
107    pub fn get_module(&self) -> Option<&str> {
108        self.custom_tags
109            .iter()
110            .find(|(k, _)| k == "module")
111            .map(|(_, v)| v.as_str())
112    }
113
114    /// @acp:summary "Gets the category from custom tags"
115    pub fn get_category(&self) -> Option<&str> {
116        self.custom_tags
117            .iter()
118            .find(|(k, _)| k == "category")
119            .map(|(_, v)| v.as_str())
120    }
121
122    /// @acp:summary "Checks if marked as readonly"
123    pub fn is_readonly(&self) -> bool {
124        self.custom_tags
125            .iter()
126            .any(|(k, v)| k == "readonly" && v == "true")
127    }
128}
129
130/// @acp:summary "Trait for parsing language-specific doc standards"
131pub trait DocStandardParser: Send + Sync {
132    /// @acp:summary "Parses a raw doc comment into structured documentation"
133    fn parse(&self, raw_comment: &str) -> ParsedDocumentation;
134
135    /// @acp:summary "Gets the standard name"
136    fn standard_name(&self) -> &'static str;
137
138    /// @acp:summary "Converts parsed documentation to ACP suggestions"
139    ///
140    /// Default implementation converts common fields to suggestions.
141    /// Override for standard-specific behavior.
142    fn to_suggestions(
143        &self,
144        parsed: &ParsedDocumentation,
145        target: &str,
146        line: usize,
147    ) -> Vec<Suggestion> {
148        let mut suggestions = Vec::new();
149
150        // Convert summary
151        if let Some(summary) = &parsed.summary {
152            let truncated = truncate_summary(summary, 100);
153            suggestions.push(Suggestion::summary(
154                target,
155                line,
156                truncated,
157                SuggestionSource::Converted,
158            ));
159        }
160
161        // Convert deprecated
162        if let Some(msg) = &parsed.deprecated {
163            suggestions.push(Suggestion::deprecated(
164                target,
165                line,
166                msg,
167                SuggestionSource::Converted,
168            ));
169        }
170
171        // Convert @see references to @acp:ref
172        for see_ref in &parsed.see_refs {
173            suggestions.push(Suggestion::new(
174                target,
175                line,
176                AnnotationType::Ref,
177                see_ref,
178                SuggestionSource::Converted,
179            ));
180        }
181
182        // Convert @todo to @acp:hack
183        for todo in &parsed.todos {
184            suggestions.push(Suggestion::new(
185                target,
186                line,
187                AnnotationType::Hack,
188                format!("reason=\"{}\"", todo),
189                SuggestionSource::Converted,
190            ));
191        }
192
193        // Convert visibility to lock level
194        if let Some(visibility) = parsed.get_visibility() {
195            let lock_level = match visibility {
196                "private" | "internal" => "restricted",
197                "protected" => "normal",
198                _ => "normal",
199            };
200            suggestions.push(Suggestion::lock(
201                target,
202                line,
203                lock_level,
204                SuggestionSource::Converted,
205            ));
206        }
207
208        // Convert @module
209        if let Some(module_name) = parsed.get_module() {
210            suggestions.push(Suggestion::new(
211                target,
212                line,
213                AnnotationType::Module,
214                module_name,
215                SuggestionSource::Converted,
216            ));
217        }
218
219        // Convert @category to domain
220        if let Some(category) = parsed.get_category() {
221            suggestions.push(Suggestion::domain(
222                target,
223                line,
224                category.to_lowercase(),
225                SuggestionSource::Converted,
226            ));
227        }
228
229        // Convert throws to AI hint
230        if !parsed.throws.is_empty() {
231            let throws_list: Vec<String> = parsed.throws.iter().map(|(t, _)| t.clone()).collect();
232            suggestions.push(Suggestion::ai_hint(
233                target,
234                line,
235                format!("throws {}", throws_list.join(", ")),
236                SuggestionSource::Converted,
237            ));
238        }
239
240        // Convert readonly to AI hint
241        if parsed.is_readonly() {
242            suggestions.push(Suggestion::ai_hint(
243                target,
244                line,
245                "readonly",
246                SuggestionSource::Converted,
247            ));
248        }
249
250        // Convert examples existence to AI hint
251        if !parsed.examples.is_empty() {
252            suggestions.push(Suggestion::ai_hint(
253                target,
254                line,
255                "has examples",
256                SuggestionSource::Converted,
257            ));
258        }
259
260        // Convert notes/warnings to AI hints
261        for note in &parsed.notes {
262            suggestions.push(Suggestion::ai_hint(
263                target,
264                line,
265                note,
266                SuggestionSource::Converted,
267            ));
268        }
269
270        suggestions
271    }
272}
273
274/// @acp:summary "Truncates a summary to the specified length"
275fn truncate_summary(summary: &str, max_len: usize) -> String {
276    let trimmed = summary.trim();
277    if trimmed.len() <= max_len {
278        trimmed.to_string()
279    } else {
280        // Find the last space before max_len to avoid cutting words
281        let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
282        format!("{}...", &trimmed[..truncate_at])
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_parsed_documentation_is_empty() {
292        let empty = ParsedDocumentation::new();
293        assert!(empty.is_empty());
294
295        let mut with_summary = ParsedDocumentation::new();
296        with_summary.summary = Some("Test".to_string());
297        assert!(!with_summary.is_empty());
298    }
299
300    #[test]
301    fn test_truncate_summary() {
302        assert_eq!(truncate_summary("Short", 100), "Short");
303        assert_eq!(
304            truncate_summary("This is a very long summary that needs to be truncated", 20),
305            "This is a very long..."
306        );
307    }
308
309    #[test]
310    fn test_parsed_documentation_getters() {
311        let mut doc = ParsedDocumentation::new();
312        doc.custom_tags
313            .push(("visibility".to_string(), "private".to_string()));
314        doc.custom_tags
315            .push(("module".to_string(), "TestModule".to_string()));
316        doc.custom_tags
317            .push(("category".to_string(), "Security".to_string()));
318        doc.custom_tags
319            .push(("readonly".to_string(), "true".to_string()));
320
321        assert_eq!(doc.get_visibility(), Some("private"));
322        assert_eq!(doc.get_module(), Some("TestModule"));
323        assert_eq!(doc.get_category(), Some("Security"));
324        assert!(doc.is_readonly());
325    }
326}