#![deny(unsafe_code)]
#![warn(rust_2018_idioms)]
#![warn(missing_docs)]
use std::collections::HashMap;
use std::io;
use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PodDoc {
pub name: Option<String>,
pub synopsis: Option<String>,
pub description: Option<String>,
pub methods: HashMap<String, String>,
}
impl PodDoc {
#[must_use]
pub fn is_empty(&self) -> bool {
self.name.is_none()
&& self.synopsis.is_none()
&& self.description.is_none()
&& self.methods.is_empty()
}
}
pub fn extract_pod_from_file(path: &Path) -> io::Result<PodDoc> {
let content = std::fs::read_to_string(path)?;
Ok(extract_pod(&content))
}
#[must_use]
pub fn extract_pod(source: &str) -> PodDoc {
let mut doc = PodDoc::default();
let mut current_section: Option<Section> = None;
let mut body = String::new();
let mut in_pod = false;
let mut in_over = false;
for line in source.lines() {
if line.starts_with("=head")
|| line.starts_with("=pod")
|| line.starts_with("=over")
|| line.starts_with("=begin")
|| line.starts_with("=for")
|| line.starts_with("=encoding")
|| line.starts_with("=item")
{
in_pod = true;
}
if !in_pod {
continue;
}
if line.starts_with("=cut") {
flush_section(&mut doc, ¤t_section, &body, in_over);
current_section = None;
body.clear();
in_pod = false;
in_over = false;
continue;
}
if line.starts_with("=over") {
in_over = true;
body.push('\n');
continue;
}
if line.starts_with("=back") {
in_over = false;
body.push('\n');
continue;
}
if line.starts_with("=item") {
let item_text = line.strip_prefix("=item").map(str::trim).unwrap_or("");
if !body.is_empty() {
body.push('\n');
}
body.push_str("- ");
body.push_str(&strip_pod_formatting(item_text));
body.push('\n');
continue;
}
if let Some(heading) = line.strip_prefix("=head1") {
flush_section(&mut doc, ¤t_section, &body, false);
body.clear();
let heading = heading.trim();
current_section = Some(match heading {
"NAME" => Section::Name,
"SYNOPSIS" => Section::Synopsis,
"DESCRIPTION" => Section::Description,
_ => Section::Other(()),
});
continue;
}
if let Some(heading) = line.strip_prefix("=head2") {
flush_section(&mut doc, ¤t_section, &body, false);
body.clear();
let heading = strip_pod_formatting(heading.trim());
current_section = Some(Section::Method(heading));
continue;
}
if line.starts_with("=pod")
|| line.starts_with("=encoding")
|| line.starts_with("=begin")
|| line.starts_with("=end")
|| line.starts_with("=for")
{
continue;
}
if current_section.is_some() && (!body.is_empty() || !line.is_empty()) {
if !body.is_empty() {
body.push('\n');
}
body.push_str(line);
}
}
flush_section(&mut doc, ¤t_section, &body, in_over);
doc
}
#[derive(Debug)]
enum Section {
Name,
Synopsis,
Description,
Method(String),
Other(()),
}
fn flush_section(doc: &mut PodDoc, section: &Option<Section>, body: &str, _in_over: bool) {
let section = match section {
Some(s) => s,
None => return,
};
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
let cleaned = strip_pod_formatting(trimmed);
match section {
Section::Name => {
doc.name = Some(cleaned);
}
Section::Synopsis => {
doc.synopsis = Some(cleaned);
}
Section::Description => {
let first_para = first_paragraph(&cleaned);
doc.description = Some(first_para);
}
Section::Method(name) => {
doc.methods.insert(name.clone(), cleaned);
}
Section::Other(_) => {
}
}
}
fn first_paragraph(text: &str) -> String {
let mut result = String::new();
for line in text.lines() {
if line.trim().is_empty() && !result.is_empty() {
break;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
result
}
fn strip_pod_formatting(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 2 < len
&& chars[i].is_ascii_alphabetic()
&& chars[i + 1] == '<'
&& is_pod_format_code(chars[i])
{
let code_char = chars[i];
let delimiter_width = opening_delimiter_width(&chars, i + 1);
i += 1 + delimiter_width;
let start = i;
let end = if delimiter_width == 1 {
let mut depth = 1;
while i < len && depth > 0 {
if chars[i] == '<' {
depth += 1;
} else if chars[i] == '>' {
depth -= 1;
}
if depth > 0 {
i += 1;
}
}
let end = i;
if i < len {
i += 1; }
end
} else {
while i < len && !has_closing_delimiter(&chars, i, delimiter_width) {
i += 1;
}
let end = i;
if i < len {
i += delimiter_width;
}
end
};
let inner = &chars[start..end];
let mut inner_str: String = inner.iter().collect();
if delimiter_width > 1 {
inner_str = trim_multidelimiter_padding(&inner_str).to_string();
}
let display = match code_char {
'L' => extract_link_display(&inner_str),
'E' => decode_pod_entity(&inner_str),
_ => strip_pod_formatting(&inner_str),
};
result.push_str(&display);
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn opening_delimiter_width(chars: &[char], start: usize) -> usize {
chars[start..].iter().take_while(|ch| **ch == '<').count()
}
fn has_closing_delimiter(chars: &[char], start: usize, delimiter_width: usize) -> bool {
chars
.get(start..start + delimiter_width)
.is_some_and(|candidate| candidate.iter().all(|ch| *ch == '>'))
}
fn trim_multidelimiter_padding(text: &str) -> &str {
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
}
fn encode_pod_link_target(target: &str) -> String {
let mut encoded = String::with_capacity(target.len());
for byte in target.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b':' | b'/') {
encoded.push(char::from(byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn escape_markdown_link_text(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\\' | '[' | ']' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
fn extract_link_display(link: &str) -> String {
if let Some(pipe_pos) = link.find('|') {
let display = escape_markdown_link_text(&strip_pod_formatting(&link[..pipe_pos]));
let target = encode_pod_link_target(link[pipe_pos + 1..].trim());
return format!("[{display}](perl-module://{target})");
}
if let Some(slash_pos) = link.find('/') {
let module = escape_markdown_link_text(&strip_pod_formatting(&link[..slash_pos]));
let target = encode_pod_link_target(link.trim());
return format!("[{module}](perl-module://{target})");
}
let display = escape_markdown_link_text(&strip_pod_formatting(link));
let target = encode_pod_link_target(link.trim());
format!("[{display}](perl-module://{target})")
}
fn decode_pod_entity(entity: &str) -> String {
match entity {
"lt" => "<".to_string(),
"gt" => ">".to_string(),
"amp" => "&".to_string(),
"quot" => "\"".to_string(),
"apos" => "'".to_string(),
"sol" => "/".to_string(),
"verbar" => "|".to_string(),
_ => decode_numeric_pod_entity(entity).unwrap_or_else(|| entity.to_string()),
}
}
fn decode_numeric_pod_entity(entity: &str) -> Option<String> {
if entity.is_empty() {
return None;
}
let codepoint =
if let Some(hex) = entity.strip_prefix("0x").or_else(|| entity.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).ok()?
} else if entity.starts_with('0') && entity.len() > 1 {
u32::from_str_radix(entity, 8).ok()?
} else {
entity.parse::<u32>().ok()?
};
char::from_u32(codepoint).map(|ch| ch.to_string())
}
fn is_pod_format_code(c: char) -> bool {
matches!(c, 'B' | 'I' | 'C' | 'L' | 'F' | 'S' | 'E' | 'X' | 'Z')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_paragraph_stops_at_first_blank_line() {
let text = "first line\nsecond line\n\nthird line";
assert_eq!(first_paragraph(text), "first line\nsecond line");
}
#[test]
fn first_paragraph_skips_leading_blank_before_text() {
let text = "\nfirst paragraph\n\nsecond paragraph";
assert_eq!(first_paragraph(text), "first paragraph");
}
#[test]
fn decode_entity_unknown_returns_name_unchanged() {
assert_eq!(decode_pod_entity("nbsp"), "nbsp");
assert_eq!(decode_pod_entity("unknown"), "unknown");
assert_eq!(decode_pod_entity("copy"), "copy");
}
#[test]
fn decode_entity_empty_returns_empty() {
assert_eq!(decode_pod_entity(""), "");
}
#[test]
fn decode_entity_numeric_codepoints() {
assert_eq!(decode_pod_entity("32"), " ");
assert_eq!(decode_pod_entity("0x20"), " ");
assert_eq!(decode_pod_entity("0X3BB"), "λ");
assert_eq!(decode_pod_entity("181"), "µ");
assert_eq!(decode_pod_entity("0x201E"), "„");
assert_eq!(decode_pod_entity("075"), "=");
}
#[test]
fn decode_entity_invalid_numeric_returns_unchanged() {
assert_eq!(decode_pod_entity("0x"), "0x");
assert_eq!(decode_pod_entity("09"), "09");
assert_eq!(decode_pod_entity("1114112"), "1114112");
assert_eq!(decode_pod_entity("0x110000"), "0x110000");
}
#[test]
fn decode_entity_known_entities() {
assert_eq!(decode_pod_entity("lt"), "<");
assert_eq!(decode_pod_entity("gt"), ">");
assert_eq!(decode_pod_entity("amp"), "&");
assert_eq!(decode_pod_entity("quot"), "\"");
assert_eq!(decode_pod_entity("apos"), "'");
assert_eq!(decode_pod_entity("sol"), "/");
assert_eq!(decode_pod_entity("verbar"), "|");
}
#[test]
fn strips_double_angle_code_formatting() {
assert_eq!(strip_pod_formatting("C<< $obj->method >>"), "$obj->method");
}
#[test]
fn double_angle_formatting_allows_single_angle_content() {
assert_eq!(strip_pod_formatting("Use C<< <=> >> for comparison"), "Use <=> for comparison");
}
#[test]
fn double_angle_link_renders_markdown() {
assert_eq!(
strip_pod_formatting("L<< display text|File::Find/The wanted function >>"),
"[display text](perl-module://File::Find/The%20wanted%20function)"
);
}
#[test]
fn strip_pod_formatting_handles_nested_text_and_entities() {
let text = "Use B<I<strict>> and C<$value E<lt> 10>";
assert_eq!(strip_pod_formatting(text), "Use strict and $value < 10");
}
#[test]
fn encode_link_empty_string() {
assert_eq!(encode_pod_link_target(""), "");
}
#[test]
fn encode_link_pure_ascii_safe_chars_pass_through() {
assert_eq!(encode_pod_link_target("-._~"), "-._~");
assert_eq!(
encode_pod_link_target("A::Module/section-name_v1.0~"),
"A::Module/section-name_v1.0~"
);
assert_eq!(
encode_pod_link_target("Module::Name/path-._~/Section"),
"Module::Name/path-._~/Section"
);
}
#[test]
fn encode_link_percent_encodes_markdown_breakers() {
assert_eq!(
encode_pod_link_target("Module Name/[section](x)"),
"Module%20Name/%5Bsection%5D%28x%29"
);
}
#[test]
fn encode_link_multibyte_utf8_cafe() {
let result = encode_pod_link_target("café");
assert_eq!(result, "caf%C3%A9");
}
#[test]
fn encode_link_multibyte_utf8_japanese() {
let result = encode_pod_link_target("日本語");
assert_eq!(result, "%E6%97%A5%E6%9C%AC%E8%AA%9E");
}
#[test]
fn encode_link_consecutive_special_chars() {
assert_eq!(encode_pod_link_target("a b"), "a%20%20b");
assert_eq!(encode_pod_link_target("((()))"), "%28%28%28%29%29%29");
}
#[test]
fn encode_link_control_chars_tab_and_newline() {
assert_eq!(encode_pod_link_target("\t"), "%09");
assert_eq!(encode_pod_link_target("\n"), "%0A");
assert_eq!(encode_pod_link_target("a\tb"), "a%09b");
}
#[test]
fn markdown_link_text_escapes_only_link_delimiters() {
let text = r"back\slash [label] (target)";
assert_eq!(escape_markdown_link_text(text), r"back\\slash \[label\] (target)");
}
}