1#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9
10use std::collections::HashMap;
11use std::io;
12use std::path::Path;
13
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct PodDoc {
17 pub name: Option<String>,
19 pub synopsis: Option<String>,
21 pub description: Option<String>,
23 pub methods: HashMap<String, String>,
25 pub arguments: Option<String>,
27 pub return_values: Option<String>,
29 pub examples: Option<String>,
31 pub see_also: Option<String>,
33}
34
35impl PodDoc {
36 #[must_use]
38 pub fn is_empty(&self) -> bool {
39 self.name.is_none()
40 && self.synopsis.is_none()
41 && self.description.is_none()
42 && self.methods.is_empty()
43 && self.arguments.is_none()
44 && self.return_values.is_none()
45 && self.examples.is_none()
46 && self.see_also.is_none()
47 }
48}
49
50pub fn extract_pod_from_file(path: &Path) -> io::Result<PodDoc> {
56 let content = std::fs::read_to_string(path)?;
57 Ok(extract_pod(&content))
58}
59
60#[must_use]
74pub fn extract_pod(source: &str) -> PodDoc {
75 let mut doc = PodDoc::default();
76 let mut current_section: Option<Section> = None;
77 let mut body = String::new();
78 let mut in_pod = false;
79 let mut in_over = false;
80
81 for line in source.lines() {
82 if line.starts_with("=head")
84 || line.starts_with("=pod")
85 || line.starts_with("=over")
86 || line.starts_with("=begin")
87 || line.starts_with("=for")
88 || line.starts_with("=encoding")
89 || line.starts_with("=item")
90 {
91 in_pod = true;
92 }
93
94 if !in_pod {
95 continue;
96 }
97
98 if line.starts_with("=cut") {
100 flush_section(&mut doc, ¤t_section, &body, in_over);
101 current_section = None;
102 body.clear();
103 in_pod = false;
104 in_over = false;
105 continue;
106 }
107
108 if line.starts_with("=over") {
110 in_over = true;
111 body.push('\n');
112 continue;
113 }
114 if line.starts_with("=back") {
115 in_over = false;
116 body.push('\n');
117 continue;
118 }
119 if line.starts_with("=item") {
120 let item_text = line.strip_prefix("=item").map(str::trim).unwrap_or("");
121 if !body.is_empty() {
122 body.push('\n');
123 }
124 body.push_str("- ");
125 body.push_str(&strip_pod_formatting(item_text));
126 body.push('\n');
127 continue;
128 }
129
130 if let Some(heading) = line.strip_prefix("=head1") {
132 flush_section(&mut doc, ¤t_section, &body, false);
133 body.clear();
134 let heading = heading.trim();
135 if let Some(section) = match heading {
136 "NAME" => Some(Section::Name),
137 "SYNOPSIS" => Some(Section::Synopsis),
138 "DESCRIPTION" => Some(Section::Description),
139 "ARGUMENTS" => Some(Section::Arguments),
140 "RETURN VALUES" => Some(Section::ReturnValues),
141 "EXAMPLES" => Some(Section::Examples),
142 "SEE ALSO" => Some(Section::SeeAlso),
143 _ => None,
144 } {
145 current_section = Some(section);
146 } else {
147 current_section = None;
148 }
149 continue;
150 }
151
152 if let Some(heading) = line.strip_prefix("=head2") {
154 flush_section(&mut doc, ¤t_section, &body, false);
155 body.clear();
156 let heading = strip_pod_formatting(heading.trim());
157 current_section = Some(Section::Method(heading));
158 continue;
159 }
160
161 if line.starts_with("=pod")
163 || line.starts_with("=encoding")
164 || line.starts_with("=begin")
165 || line.starts_with("=end")
166 || line.starts_with("=for")
167 {
168 continue;
169 }
170
171 if current_section.is_some() && (!body.is_empty() || !line.is_empty()) {
173 if !body.is_empty() {
174 body.push('\n');
175 }
176 body.push_str(line);
177 }
178 }
179
180 flush_section(&mut doc, ¤t_section, &body, in_over);
182
183 doc
184}
185
186#[derive(Debug)]
187enum Section {
188 Name,
189 Synopsis,
190 Description,
191 Arguments,
192 ReturnValues,
193 Examples,
194 SeeAlso,
195 Method(String),
196}
197
198fn flush_section(doc: &mut PodDoc, section: &Option<Section>, body: &str, _in_over: bool) {
218 let section = match section {
219 Some(s) => s,
220 None => return,
221 };
222
223 let trimmed = body.trim();
224 if trimmed.is_empty() {
225 return;
226 }
227
228 let cleaned = strip_pod_formatting(trimmed);
229
230 match section {
231 Section::Name => {
232 doc.name = Some(cleaned);
233 }
234 Section::Synopsis => {
235 doc.synopsis = Some(cleaned);
236 }
237 Section::Description => {
238 let first_para = first_paragraph(&cleaned);
240 doc.description = Some(first_para);
241 }
242 Section::Arguments => {
243 doc.arguments = Some(cleaned);
244 }
245 Section::ReturnValues => {
246 doc.return_values = Some(cleaned);
247 }
248 Section::Examples => {
249 doc.examples = Some(cleaned);
250 }
251 Section::SeeAlso => {
252 doc.see_also = Some(cleaned);
253 }
254 Section::Method(name) => {
255 doc.methods.insert(name.clone(), cleaned);
256 }
257 }
258}
259
260fn first_paragraph(text: &str) -> String {
262 let mut result = String::new();
263 for line in text.lines() {
264 if line.trim().is_empty() && !result.is_empty() {
265 break;
266 }
267 if !result.is_empty() {
268 result.push('\n');
269 }
270 result.push_str(line);
271 }
272 result
273}
274
275const MAX_POD_FORMATTING_DEPTH: usize = 100;
283
284fn strip_pod_formatting(text: &str) -> String {
290 strip_pod_formatting_depth(text, 0)
291}
292
293fn strip_pod_formatting_depth(text: &str, depth: usize) -> String {
300 if depth >= MAX_POD_FORMATTING_DEPTH {
301 return text.to_string();
302 }
303
304 let mut result = String::with_capacity(text.len());
305 let chars: Vec<char> = text.chars().collect();
306 let len = chars.len();
307 let mut i = 0;
308
309 while i < len {
310 if i + 2 < len
312 && chars[i].is_ascii_alphabetic()
313 && chars[i + 1] == '<'
314 && is_pod_format_code(chars[i])
315 {
316 let code_char = chars[i];
317 let delimiter_width = opening_delimiter_width(&chars, i + 1);
318 i += 1 + delimiter_width; let start = i;
321 let end = if delimiter_width == 1 {
322 let mut depth = 1;
324 while i < len && depth > 0 {
325 if chars[i] == '<' {
326 depth += 1;
327 } else if chars[i] == '>' {
328 depth -= 1;
329 }
330 if depth > 0 {
331 i += 1;
332 }
333 }
334 let end = i;
335 if i < len {
336 i += 1; }
338 end
339 } else {
340 while i < len && !has_closing_delimiter(&chars, i, delimiter_width) {
343 i += 1;
344 }
345 let end = i;
346 if i < len {
347 i += delimiter_width;
348 }
349 end
350 };
351
352 let inner = &chars[start..end];
353 let mut inner_str: String = inner.iter().collect();
354 if delimiter_width > 1 {
355 inner_str = trim_multidelimiter_padding(&inner_str).to_string();
356 }
357
358 let display = match code_char {
359 'L' => extract_link_display(&inner_str, depth + 1),
360 'E' => decode_pod_entity(&inner_str),
361 _ => strip_pod_formatting_depth(&inner_str, depth + 1),
362 };
363
364 result.push_str(&display);
365 } else {
366 result.push(chars[i]);
367 i += 1;
368 }
369 }
370
371 result
372}
373
374fn opening_delimiter_width(chars: &[char], start: usize) -> usize {
375 chars[start..].iter().take_while(|ch| **ch == '<').count()
376}
377
378fn has_closing_delimiter(chars: &[char], start: usize, delimiter_width: usize) -> bool {
379 chars
380 .get(start..start + delimiter_width)
381 .is_some_and(|candidate| candidate.iter().all(|ch| *ch == '>'))
382}
383
384fn trim_multidelimiter_padding(text: &str) -> &str {
385 text.trim_matches(|ch: char| ch.is_ascii_whitespace())
386}
387
388fn encode_pod_link_target(target: &str) -> String {
393 let mut encoded = String::with_capacity(target.len());
394 for byte in target.bytes() {
395 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b':' | b'/') {
396 encoded.push(char::from(byte));
397 } else {
398 encoded.push_str(&format!("%{byte:02X}"));
399 }
400 }
401 encoded
402}
403
404fn escape_markdown_link_text(text: &str) -> String {
405 let mut escaped = String::with_capacity(text.len());
406 for ch in text.chars() {
407 match ch {
408 '\\' | '[' | ']' => {
409 escaped.push('\\');
410 escaped.push(ch);
411 }
412 _ => escaped.push(ch),
413 }
414 }
415 escaped
416}
417
418fn extract_link_display(link: &str, depth: usize) -> String {
430 if let Some(pipe_pos) = link.find('|') {
432 let display =
433 escape_markdown_link_text(&strip_pod_formatting_depth(link[..pipe_pos].trim(), depth));
434 let target = encode_pod_link_target(link[pipe_pos + 1..].trim());
435 return format!("[{display}](perl-module://{target})");
436 }
437 if let Some(slash_pos) = link.find('/') {
439 let module =
440 escape_markdown_link_text(&strip_pod_formatting_depth(link[..slash_pos].trim(), depth));
441 let target = encode_pod_link_target(link.trim());
442 return format!("[{module}](perl-module://{target})");
443 }
444 let display = escape_markdown_link_text(&strip_pod_formatting_depth(link.trim(), depth));
446 let target = encode_pod_link_target(link.trim());
447 format!("[{display}](perl-module://{target})")
448}
449
450fn decode_pod_entity(entity: &str) -> String {
465 match entity {
466 "lt" => "<".to_string(),
467 "gt" => ">".to_string(),
468 "amp" => "&".to_string(),
469 "quot" => "\"".to_string(),
470 "apos" => "'".to_string(),
471 "sol" => "/".to_string(),
472 "verbar" => "|".to_string(),
473 _ => decode_numeric_pod_entity(entity).unwrap_or_else(|| entity.to_string()),
474 }
475}
476
477fn decode_numeric_pod_entity(entity: &str) -> Option<String> {
478 if entity.is_empty() {
479 return None;
480 }
481
482 let codepoint =
483 if let Some(hex) = entity.strip_prefix("0x").or_else(|| entity.strip_prefix("0X")) {
484 u32::from_str_radix(hex, 16).ok()?
485 } else if entity.starts_with('0') && entity.len() > 1 {
486 u32::from_str_radix(entity, 8).ok()?
487 } else {
488 entity.parse::<u32>().ok()?
489 };
490
491 char::from_u32(codepoint).map(|ch| ch.to_string())
492}
493
494fn is_pod_format_code(c: char) -> bool {
495 matches!(c, 'B' | 'I' | 'C' | 'L' | 'F' | 'S' | 'E' | 'X' | 'Z')
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn first_paragraph_stops_at_first_blank_line() {
504 let text = "first line\nsecond line\n\nthird line";
505
506 assert_eq!(first_paragraph(text), "first line\nsecond line");
507 }
508
509 #[test]
510 fn first_paragraph_skips_leading_blank_before_text() {
511 let text = "\nfirst paragraph\n\nsecond paragraph";
512
513 assert_eq!(first_paragraph(text), "first paragraph");
514 }
515
516 #[test]
519 fn decode_entity_unknown_returns_name_unchanged() {
520 assert_eq!(decode_pod_entity("nbsp"), "nbsp");
522 assert_eq!(decode_pod_entity("unknown"), "unknown");
523 assert_eq!(decode_pod_entity("copy"), "copy");
524 }
525
526 #[test]
527 fn decode_entity_empty_returns_empty() {
528 assert_eq!(decode_pod_entity(""), "");
530 }
531
532 #[test]
533 fn decode_entity_numeric_codepoints() {
534 assert_eq!(decode_pod_entity("32"), " ");
535 assert_eq!(decode_pod_entity("0x20"), " ");
536 assert_eq!(decode_pod_entity("0X3BB"), "λ");
537 assert_eq!(decode_pod_entity("181"), "µ");
538 assert_eq!(decode_pod_entity("0x201E"), "„");
539 assert_eq!(decode_pod_entity("075"), "=");
540 }
541
542 #[test]
543 fn decode_entity_invalid_numeric_returns_unchanged() {
544 assert_eq!(decode_pod_entity("0x"), "0x");
545 assert_eq!(decode_pod_entity("09"), "09");
546 assert_eq!(decode_pod_entity("1114112"), "1114112");
547 assert_eq!(decode_pod_entity("0x110000"), "0x110000");
548 }
549
550 #[test]
551 fn decode_entity_known_entities() {
552 assert_eq!(decode_pod_entity("lt"), "<");
553 assert_eq!(decode_pod_entity("gt"), ">");
554 assert_eq!(decode_pod_entity("amp"), "&");
555 assert_eq!(decode_pod_entity("quot"), "\"");
556 assert_eq!(decode_pod_entity("apos"), "'");
557 assert_eq!(decode_pod_entity("sol"), "/");
558 assert_eq!(decode_pod_entity("verbar"), "|");
559 }
560
561 #[test]
564 fn strips_double_angle_code_formatting() {
565 assert_eq!(strip_pod_formatting("C<< $obj->method >>"), "$obj->method");
566 }
567
568 #[test]
569 fn double_angle_formatting_allows_single_angle_content() {
570 assert_eq!(strip_pod_formatting("Use C<< <=> >> for comparison"), "Use <=> for comparison");
571 }
572
573 #[test]
574 fn double_angle_link_renders_markdown() {
575 assert_eq!(
576 strip_pod_formatting("L<< display text|File::Find/The wanted function >>"),
577 "[display text](perl-module://File::Find/The%20wanted%20function)"
578 );
579 }
580
581 #[test]
584 fn link_pipe_form_trims_display_and_target() {
585 assert_eq!(strip_pod_formatting("L< text | target >"), "[text](perl-module://target)");
588 }
589
590 #[test]
591 fn link_slash_form_trims_module_display() {
592 assert_eq!(
595 strip_pod_formatting("L<Module / Section>"),
596 "[Module](perl-module://Module%20/%20Section)"
597 );
598 }
599
600 #[test]
601 fn link_simple_form_trims_display() {
602 assert_eq!(
605 strip_pod_formatting("L< Module::Name >"),
606 "[Module::Name](perl-module://Module::Name)"
607 );
608 }
609
610 #[test]
611 fn strip_pod_formatting_handles_nested_text_and_entities() {
612 let text = "Use B<I<strict>> and C<$value E<lt> 10>";
613
614 assert_eq!(strip_pod_formatting(text), "Use strict and $value < 10");
615 }
616
617 #[test]
618 fn strip_pod_formatting_deeply_nested_does_not_overflow_stack() {
619 const NESTING: usize = 5000;
623 let mut text = String::from("core");
624 for _ in 0..NESTING {
625 text = format!("B<I<{text}>>");
626 }
627
628 let stripped = strip_pod_formatting(&text);
630
631 assert!(stripped.contains("core"), "expected innermost content to remain");
633 }
637
638 #[test]
639 fn extract_pod_deeply_nested_head2_does_not_overflow_stack() {
640 const NESTING: usize = 5000;
642 let mut heading = String::from("name");
643 for _ in 0..NESTING {
644 heading = format!("B<I<{heading}>>");
645 }
646 let source = format!("=head2 {heading}\n\nbody text\n\n=cut\n");
647
648 let doc = extract_pod(&source);
650
651 assert_eq!(doc.methods.len(), 1, "expected exactly one method section");
652 }
653
654 #[test]
657 fn encode_link_empty_string() {
658 assert_eq!(encode_pod_link_target(""), "");
659 }
660
661 #[test]
662 fn encode_link_pure_ascii_safe_chars_pass_through() {
663 assert_eq!(encode_pod_link_target("-._~"), "-._~");
665 assert_eq!(
666 encode_pod_link_target("A::Module/section-name_v1.0~"),
667 "A::Module/section-name_v1.0~"
668 );
669 assert_eq!(
670 encode_pod_link_target("Module::Name/path-._~/Section"),
671 "Module::Name/path-._~/Section"
672 );
673 }
674
675 #[test]
676 fn encode_link_percent_encodes_markdown_breakers() {
677 assert_eq!(
678 encode_pod_link_target("Module Name/[section](x)"),
679 "Module%20Name/%5Bsection%5D%28x%29"
680 );
681 }
682
683 #[test]
684 fn encode_link_multibyte_utf8_cafe() {
685 let result = encode_pod_link_target("café");
687 assert_eq!(result, "caf%C3%A9");
688 }
689
690 #[test]
691 fn encode_link_multibyte_utf8_japanese() {
692 let result = encode_pod_link_target("日本語");
694 assert_eq!(result, "%E6%97%A5%E6%9C%AC%E8%AA%9E");
696 }
697
698 #[test]
699 fn encode_link_consecutive_special_chars() {
700 assert_eq!(encode_pod_link_target("a b"), "a%20%20b");
702 assert_eq!(encode_pod_link_target("((()))"), "%28%28%28%29%29%29");
703 }
704
705 #[test]
706 fn encode_link_control_chars_tab_and_newline() {
707 assert_eq!(encode_pod_link_target("\t"), "%09");
709 assert_eq!(encode_pod_link_target("\n"), "%0A");
710 assert_eq!(encode_pod_link_target("a\tb"), "a%09b");
711 }
712
713 #[test]
714 fn markdown_link_text_escapes_only_link_delimiters() {
715 let text = r"back\slash [label] (target)";
716
717 assert_eq!(escape_markdown_link_text(text), r"back\\slash \[label\] (target)");
718 }
719}