#![deny(unsafe_code)]
#![warn(rust_2018_idioms)]
#![warn(missing_docs)]
#![warn(clippy::all)]
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 = heading.trim().to_string();
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];
i += 2;
let mut depth = 1;
let start = i;
while i < len && depth > 0 {
if chars[i] == '<' {
depth += 1;
} else if chars[i] == '>' {
depth -= 1;
}
if depth > 0 {
i += 1;
}
}
let inner = &chars[start..i];
let inner_str: String = inner.iter().collect();
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);
if i < len {
i += 1; }
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
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(),
_ => entity.to_string(),
}
}
fn is_pod_format_code(c: char) -> bool {
matches!(c, 'B' | 'I' | 'C' | 'L' | 'F' | 'S' | 'E' | 'X' | 'Z')
}