1use std::fmt::Write;
20
21#[derive(Debug, Clone)]
26pub struct QuickRefEntry {
27 pub name: String,
29
30 pub kind: &'static str,
32
33 pub anchor: String,
35
36 pub summary: String,
38}
39
40impl QuickRefEntry {
41 #[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#[derive(Debug, Clone, Default)]
70pub struct QuickRefGenerator;
71
72impl QuickRefGenerator {
73 #[must_use]
75 pub const fn new() -> Self {
76 Self
77 }
78
79 #[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 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#[must_use]
146pub fn extract_summary(docs: Option<&str>) -> String {
147 let Some(docs) = docs else {
148 return String::new();
149 };
150
151 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 if trimmed.is_empty() {
160 if found_content {
161 break;
162 }
163 continue;
164 }
165
166 found_content = true;
167
168 if !collected.is_empty() {
170 collected.push(' ');
171 }
172
173 _ = write!(collected, "{trimmed}");
174
175 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 collected.trim_end_matches([',', ';', ':']).to_string()
187}
188
189const 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
195fn try_extract_sentence(text: &str) -> Option<String> {
200 let mut search_start = 0;
201
202 loop {
203 let pos = text[search_start..].find(". ")?;
205 let absolute_pos = search_start + pos;
206
207 let prefix = &text[..=absolute_pos];
209 let is_abbreviation = ABBREVIATIONS.iter().any(|abbr| prefix.ends_with(abbr));
210
211 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 search_start = absolute_pos + 2;
225 continue;
226 }
227
228 return Some(text[..=absolute_pos].to_string());
230 }
231}
232
233#[cfg(test)]
240fn extract_first_sentence(text: &str) -> &str {
241 let mut search_start = 0;
242
243 loop {
244 let Some(pos) = text[search_start..].find(". ") else {
246 return text;
248 };
249
250 let absolute_pos = search_start + pos;
251
252 let prefix = &text[..=absolute_pos];
254 let is_abbreviation = ABBREVIATIONS.iter().any(|abbr| prefix.ends_with(abbr));
255
256 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 search_start = absolute_pos + 2;
270 continue;
271 }
272
273 return &text[..=absolute_pos]; }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[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 #[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 #[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 #[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 #[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 assert_eq!(extract_summary(Some("Short. More.")), "Short.");
505 }
506}