cargo_docs_md/generator/
quick_ref.rs

1//! Quick Reference table generation for module documentation.
2//!
3//! This module provides [`QuickRefGenerator`] which generates a markdown table
4//! summarizing all public items in a module at a glance. The table shows item name,
5//! kind, and first-sentence description.
6//!
7//! # Example Output
8//!
9//! ```markdown
10//! ## Quick Reference
11//!
12//! | Item | Kind | Description |
13//! |------|------|-------------|
14//! | [`Parser`](#parser) | struct | JSON parser with streaming support |
15//! | [`Value`](#value) | enum | Dynamic JSON value type |
16//! | [`parse`](#parse) | fn | Parses a JSON string into a Value |
17//! ```
18
19use std::fmt::Write;
20
21/// An entry in the quick reference table.
22///
23/// Each entry represents a single public item with its name, kind,
24/// anchor link, and first-sentence summary.
25#[derive(Debug, Clone)]
26pub struct QuickRefEntry {
27    /// Display name for this entry.
28    pub name: String,
29
30    /// Item kind (struct, enum, trait, fn, etc.).
31    pub kind: &'static str,
32
33    /// Anchor link target (without the `#` prefix).
34    pub anchor: String,
35
36    /// First-sentence summary from doc comment.
37    pub summary: String,
38}
39
40impl QuickRefEntry {
41    /// Create a new quick reference entry.
42    ///
43    /// # Arguments
44    ///
45    /// * `name` - Display name for the entry
46    /// * `kind` - Item kind (struct, enum, fn, etc.)
47    /// * `anchor` - Anchor link target (without `#`)
48    /// * `summary` - First-sentence summary
49    #[must_use]
50    pub fn new(
51        name: impl Into<String>,
52        kind: &'static str,
53        anchor: impl Into<String>,
54        summary: impl Into<String>,
55    ) -> Self {
56        Self {
57            name: name.into(),
58            kind,
59            anchor: anchor.into(),
60            summary: summary.into(),
61        }
62    }
63}
64
65/// Generator for markdown quick reference tables.
66///
67/// The generator creates a table summarizing all items with links,
68/// kinds, and first-sentence descriptions.
69#[derive(Debug, Clone, Default)]
70pub struct QuickRefGenerator;
71
72impl QuickRefGenerator {
73    /// Create a new quick reference generator.
74    #[must_use]
75    pub const fn new() -> Self {
76        Self
77    }
78
79    /// Generate a markdown quick reference table from the given entries.
80    ///
81    /// Returns an empty string if there are no entries.
82    ///
83    /// # Arguments
84    ///
85    /// * `entries` - Quick reference entries to include in the table
86    ///
87    /// # Returns
88    ///
89    /// A formatted markdown table string.
90    #[must_use]
91    pub fn generate(&self, entries: &[QuickRefEntry]) -> String {
92        if entries.is_empty() {
93            return String::new();
94        }
95
96        let mut md = String::new();
97        _ = write!(
98            md,
99            "## Quick Reference\n\n\
100             | Item | Kind | Description |\n\
101             |------|------|-------------|\n"
102        );
103
104        for entry in entries {
105            // Escape pipe characters in summary to prevent table breakage
106            let escaped_summary = entry.summary.replace('|', "\\|");
107            _ = writeln!(
108                md,
109                "| [`{}`](#{}) | {} | {} |",
110                entry.name, entry.anchor, entry.kind, escaped_summary
111            );
112        }
113
114        md.push('\n');
115        md
116    }
117}
118
119/// Extract the first sentence from a documentation string.
120///
121/// This function handles sentences that span multiple lines by joining
122/// consecutive non-empty lines until a sentence boundary is found.
123/// A blank line always terminates the paragraph.
124///
125/// # Arguments
126///
127/// * `docs` - Optional documentation string
128///
129/// # Returns
130///
131/// The first sentence, or an empty string if no docs.
132///
133/// # Examples
134///
135/// ```ignore
136/// assert_eq!(extract_summary(Some("A parser. With more.")), "A parser.");
137/// assert_eq!(extract_summary(Some("Single sentence")), "Single sentence");
138/// assert_eq!(extract_summary(None), "");
139/// // Handles wrapped sentences:
140/// assert_eq!(
141///     extract_summary(Some("A long sentence that\nspans multiple lines. More.")),
142///     "A long sentence that spans multiple lines."
143/// );
144/// ```
145#[must_use]
146pub fn extract_summary(docs: Option<&str>) -> String {
147    let Some(docs) = docs else {
148        return String::new();
149    };
150
151    // Collect lines until we find a sentence end or hit a blank line
152    let mut collected = String::new();
153    let mut found_content = false;
154
155    for line in docs.lines() {
156        let trimmed = line.trim();
157
158        // Blank line terminates the first paragraph
159        if trimmed.is_empty() {
160            if found_content {
161                break;
162            }
163            continue;
164        }
165
166        found_content = true;
167
168        // Add space between lines (unless at start)
169        if !collected.is_empty() {
170            collected.push(' ');
171        }
172
173        _ = write!(collected, "{trimmed}");
174
175        // Check if we've found a sentence end
176        if let Some(sentence) = try_extract_sentence(&collected) {
177            return sentence.trim_end_matches([',', ';', ':']).to_string();
178        }
179    }
180
181    if collected.is_empty() {
182        return String::new();
183    }
184
185    // No sentence boundary found - return collected text
186    collected.trim_end_matches([',', ';', ':']).to_string()
187}
188
189/// Common abbreviations that shouldn't end sentences.
190const ABBREVIATIONS: &[&str] = &[
191    "e.g.", "i.e.", "etc.", "vs.", "cf.", "Dr.", "Mr.", "Mrs.", "Ms.", "Jr.", "Sr.", "Inc.",
192    "Ltd.", "Corp.", "viz.", "approx.", "dept.", "est.", "fig.", "no.", "vol.",
193];
194
195/// Try to extract a complete first sentence from text.
196///
197/// Returns `Some(sentence)` if a sentence boundary (`. ` not part of abbreviation
198/// or version number) is found, otherwise `None`.
199fn try_extract_sentence(text: &str) -> Option<String> {
200    let mut search_start = 0;
201
202    loop {
203        // Find next ". " pattern
204        let pos = text[search_start..].find(". ")?;
205        let absolute_pos = search_start + pos;
206
207        // Check if this is part of an abbreviation
208        let prefix = &text[..=absolute_pos];
209        let is_abbreviation = ABBREVIATIONS.iter().any(|abbr| prefix.ends_with(abbr));
210
211        // Check if this looks like a version number (digit before and after the dot)
212        let is_version = absolute_pos > 0
213            && text[..absolute_pos]
214                .chars()
215                .last()
216                .is_some_and(|c| c.is_ascii_digit())
217            && text[absolute_pos + 2..]
218                .chars()
219                .next()
220                .is_some_and(|c| c.is_ascii_digit());
221
222        if is_abbreviation || is_version {
223            // Skip this occurrence and continue searching
224            search_start = absolute_pos + 2;
225            continue;
226        }
227
228        // Found a real sentence end
229        return Some(text[..=absolute_pos].to_string());
230    }
231}
232
233/// Extract the first sentence from a line of text.
234///
235/// Handles edge cases like:
236/// - "e.g." and "i.e." abbreviations
237/// - Version numbers like "1.0"
238/// - URLs with dots
239#[cfg(test)]
240fn extract_first_sentence(text: &str) -> &str {
241    let mut search_start = 0;
242
243    loop {
244        // Find next ". " pattern
245        let Some(pos) = text[search_start..].find(". ") else {
246            // No more ". " found, return the whole text
247            return text;
248        };
249
250        let absolute_pos = search_start + pos;
251
252        // Check if this is part of an abbreviation
253        let prefix = &text[..=absolute_pos];
254        let is_abbreviation = ABBREVIATIONS.iter().any(|abbr| prefix.ends_with(abbr));
255
256        // Check if this looks like a version number (digit before and after the dot)
257        let is_version = absolute_pos > 0
258            && text[..absolute_pos]
259                .chars()
260                .last()
261                .is_some_and(|c| c.is_ascii_digit())
262            && text[absolute_pos + 2..]
263                .chars()
264                .next()
265                .is_some_and(|c| c.is_ascii_digit());
266
267        if is_abbreviation || is_version {
268            // Skip this occurrence and continue searching
269            search_start = absolute_pos + 2;
270            continue;
271        }
272
273        // Found a real sentence end
274        return &text[..=absolute_pos]; // Include the period
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    // =========================================================================
283    // QuickRefEntry tests
284    // =========================================================================
285
286    #[test]
287    fn quick_ref_entry_new() {
288        let entry = QuickRefEntry::new("Parser", "struct", "parser", "A JSON parser");
289        assert_eq!(entry.name, "Parser");
290        assert_eq!(entry.kind, "struct");
291        assert_eq!(entry.anchor, "parser");
292        assert_eq!(entry.summary, "A JSON parser");
293    }
294
295    // =========================================================================
296    // QuickRefGenerator tests
297    // =========================================================================
298
299    #[test]
300    fn quick_ref_empty_entries() {
301        let generator = QuickRefGenerator::new();
302        let result = generator.generate(&[]);
303        assert!(result.is_empty());
304    }
305
306    #[test]
307    fn quick_ref_single_entry() {
308        let generator = QuickRefGenerator::new();
309        let entries = vec![QuickRefEntry::new("Parser", "struct", "parser", "A parser")];
310
311        let result = generator.generate(&entries);
312
313        assert!(result.contains("## Quick Reference"));
314        assert!(result.contains("| Item | Kind | Description |"));
315        assert!(result.contains("| [`Parser`](#parser) | struct | A parser |"));
316    }
317
318    #[test]
319    fn quick_ref_multiple_entries() {
320        let generator = QuickRefGenerator::new();
321        let entries = vec![
322            QuickRefEntry::new("Parser", "struct", "parser", "A parser"),
323            QuickRefEntry::new("Value", "enum", "value", "A value type"),
324            QuickRefEntry::new("parse", "fn", "parse", "Parse a string"),
325        ];
326
327        let result = generator.generate(&entries);
328
329        assert!(result.contains("| [`Parser`](#parser) | struct | A parser |"));
330        assert!(result.contains("| [`Value`](#value) | enum | A value type |"));
331        assert!(result.contains("| [`parse`](#parse) | fn | Parse a string |"));
332    }
333
334    #[test]
335    fn quick_ref_escapes_pipe_in_summary() {
336        let generator = QuickRefGenerator::new();
337        let entries = vec![QuickRefEntry::new(
338            "Choice",
339            "enum",
340            "choice",
341            "Either A | B",
342        )];
343
344        let result = generator.generate(&entries);
345
346        assert!(result.contains(r"Either A \| B"));
347    }
348
349    // =========================================================================
350    // extract_summary tests
351    // =========================================================================
352
353    #[test]
354    fn extract_summary_none() {
355        assert_eq!(extract_summary(None), "");
356    }
357
358    #[test]
359    fn extract_summary_empty() {
360        assert_eq!(extract_summary(Some("")), "");
361        assert_eq!(extract_summary(Some("   ")), "");
362        assert_eq!(extract_summary(Some("\n\n")), "");
363    }
364
365    #[test]
366    fn extract_summary_single_line() {
367        assert_eq!(extract_summary(Some("A simple parser")), "A simple parser");
368    }
369
370    #[test]
371    fn extract_summary_first_sentence() {
372        assert_eq!(
373            extract_summary(Some("A parser. With more details.")),
374            "A parser."
375        );
376    }
377
378    #[test]
379    fn extract_summary_multiline() {
380        assert_eq!(
381            extract_summary(Some("First line.\nSecond line.")),
382            "First line."
383        );
384    }
385
386    #[test]
387    fn extract_summary_leading_whitespace() {
388        assert_eq!(extract_summary(Some("\n  First line")), "First line");
389    }
390
391    #[test]
392    fn extract_summary_preserves_eg_abbreviation() {
393        assert_eq!(
394            extract_summary(Some("Use e.g. this method. Then do more.")),
395            "Use e.g. this method."
396        );
397    }
398
399    #[test]
400    fn extract_summary_preserves_version_numbers() {
401        assert_eq!(
402            extract_summary(Some("Version 1.0 is here. More info.")),
403            "Version 1.0 is here."
404        );
405    }
406
407    #[test]
408    fn extract_summary_strips_trailing_punctuation() {
409        assert_eq!(extract_summary(Some("A value,")), "A value");
410        assert_eq!(extract_summary(Some("A value;")), "A value");
411        assert_eq!(extract_summary(Some("A value:")), "A value");
412    }
413
414    #[test]
415    fn extract_summary_keeps_trailing_period() {
416        assert_eq!(
417            extract_summary(Some("A complete sentence.")),
418            "A complete sentence."
419        );
420    }
421
422    // =========================================================================
423    // extract_first_sentence tests
424    // =========================================================================
425
426    #[test]
427    fn first_sentence_no_period() {
428        assert_eq!(extract_first_sentence("No period here"), "No period here");
429    }
430
431    #[test]
432    fn first_sentence_single_sentence() {
433        assert_eq!(
434            extract_first_sentence("One sentence. Two sentences."),
435            "One sentence."
436        );
437    }
438
439    #[test]
440    fn first_sentence_ie_abbreviation() {
441        assert_eq!(
442            extract_first_sentence("That is i.e. an example. More text."),
443            "That is i.e. an example."
444        );
445    }
446
447    #[test]
448    fn first_sentence_version_number() {
449        assert_eq!(
450            extract_first_sentence("Supports version 2.0 and up. Details follow."),
451            "Supports version 2.0 and up."
452        );
453    }
454
455    // =========================================================================
456    // Multiline sentence extraction tests
457    // =========================================================================
458
459    #[test]
460    fn extract_summary_wrapped_sentence() {
461        assert_eq!(
462            extract_summary(Some(
463                "A long sentence that\nspans multiple lines. More text."
464            )),
465            "A long sentence that spans multiple lines."
466        );
467    }
468
469    #[test]
470    fn extract_summary_wrapped_no_sentence_end() {
471        assert_eq!(
472            extract_summary(Some("A sentence that\nspans lines")),
473            "A sentence that spans lines"
474        );
475    }
476
477    #[test]
478    fn extract_summary_blank_line_terminates() {
479        assert_eq!(
480            extract_summary(Some("First paragraph\n\nSecond paragraph")),
481            "First paragraph"
482        );
483    }
484
485    #[test]
486    fn extract_summary_wrapped_with_abbreviation() {
487        assert_eq!(
488            extract_summary(Some("This is e.g. an\nexample sentence. More.")),
489            "This is e.g. an example sentence."
490        );
491    }
492
493    #[test]
494    fn extract_summary_three_lines() {
495        assert_eq!(
496            extract_summary(Some("Line one\nline two\nline three. Done.")),
497            "Line one line two line three."
498        );
499    }
500
501    #[test]
502    fn extract_summary_preserves_single_sentence_behavior() {
503        // Ensure single-line behavior is unchanged
504        assert_eq!(extract_summary(Some("Short. More.")), "Short.");
505    }
506}