Skip to main content

perl_pod/
lib.rs

1//! POD documentation extractor for Perl `.pm` files.
2//!
3//! Parses POD (Plain Old Documentation) sections from Perl source files and
4//! returns structured documentation suitable for hover display in an LSP.
5
6#![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/// Extracted POD documentation from a Perl module.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct PodDoc {
17    /// Module name and optional one-line description from `=head1 NAME`.
18    pub name: Option<String>,
19    /// Usage example from `=head1 SYNOPSIS`.
20    pub synopsis: Option<String>,
21    /// First paragraph of `=head1 DESCRIPTION`.
22    pub description: Option<String>,
23    /// Method/function docs keyed by name, from `=head2 method_name`.
24    pub methods: HashMap<String, String>,
25    /// Parameters from `=head1 ARGUMENTS`.
26    pub arguments: Option<String>,
27    /// Return value documentation from `=head1 RETURN VALUES`.
28    pub return_values: Option<String>,
29    /// Usage examples from `=head1 EXAMPLES`.
30    pub examples: Option<String>,
31    /// Related modules from `=head1 SEE ALSO`.
32    pub see_also: Option<String>,
33}
34
35impl PodDoc {
36    /// Returns `true` if no documentation was extracted.
37    #[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
50/// Read a file and extract its POD documentation.
51///
52/// # Errors
53///
54/// Returns an I/O error if the file cannot be read.
55pub 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/// Extract POD documentation from a string of Perl source code.
61///
62/// Parses POD markup from the source string and extracts structured documentation
63/// for the NAME, SYNOPSIS, DESCRIPTION sections, and method documentation (head2).
64///
65/// # Arguments
66///
67/// * `source` - Perl source code containing POD documentation
68///
69/// # Returns
70///
71/// A `PodDoc` containing the extracted documentation fields. Empty fields indicate
72/// the corresponding POD section was not present in the source.
73#[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        // Detect POD start directives
83        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        // =cut ends POD
99        if line.starts_with("=cut") {
100            flush_section(&mut doc, &current_section, &body, in_over);
101            current_section = None;
102            body.clear();
103            in_pod = false;
104            in_over = false;
105            continue;
106        }
107
108        // =over / =item / =back for lists
109        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        // New head1 section
131        if let Some(heading) = line.strip_prefix("=head1") {
132            flush_section(&mut doc, &current_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        // New head2 section — treated as method documentation
153        if let Some(heading) = line.strip_prefix("=head2") {
154            flush_section(&mut doc, &current_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        // Skip other directives
162        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        // Accumulate body text
172        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 any remaining section (POD can end at EOF without =cut)
181    flush_section(&mut doc, &current_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
198/// Stores accumulated body text into the appropriate `PodDoc` field.
199///
200/// Called when a POD section ends (new section starts, `=cut`, or EOF).
201/// The body text is cleaned of POD formatting and stored based on section type:
202/// - `Name` → `PodDoc::name`
203/// - `Synopsis` → `PodDoc::synopsis`
204/// - `Description` → `PodDoc::description` (first paragraph only)
205/// - `Arguments` → `PodDoc::arguments`
206/// - `ReturnValues` → `PodDoc::return_values`
207/// - `Examples` → `PodDoc::examples`
208/// - `SeeAlso` → `PodDoc::see_also`
209/// - `Method(name)` → `PodDoc::methods` entry
210///
211/// # Arguments
212///
213/// * `doc` - The `PodDoc` to store extracted content into
214/// * `section` - The section type being flushed
215/// * `body` - Accumulated raw text for the section
216/// * `_in_over` - Whether inside an `=over`/`=back` block (unused, for future expansion)
217fn 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            // Take only the first paragraph
239            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
260/// Extract the first paragraph (text before the first blank line).
261fn 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
275/// Maximum nesting depth for POD inline formatting codes.
276///
277/// POD formatting codes (`B<I<...>>`, `L<...>`, etc.) are stripped recursively,
278/// one level per call. Pathological or malicious input with extreme nesting could
279/// otherwise exhaust the stack. Past this cap, inner content is returned verbatim
280/// (with delimiters already removed) rather than recursing further. Real-world POD
281/// never nests anywhere near this deep.
282const MAX_POD_FORMATTING_DEPTH: usize = 100;
283
284/// Strip POD inline formatting codes: `B<bold>`, `I<italic>`, `C<code>`, `L<link>`,
285/// and decode common `E<>` entities.
286///
287/// Handles simple (non-nested) formatting codes. Nested codes like `B<I<text>>`
288/// are handled by stripping outer codes first.
289fn strip_pod_formatting(text: &str) -> String {
290    strip_pod_formatting_depth(text, 0)
291}
292
293/// Depth-bounded implementation of [`strip_pod_formatting`].
294///
295/// `depth` tracks how many levels of formatting-code recursion have already
296/// occurred. Once it reaches [`MAX_POD_FORMATTING_DEPTH`], inner content is
297/// emitted verbatim instead of recursing, guarding against stack overflow on
298/// adversarially deep input such as `B<I<B<I<...>>>>`.
299fn 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        // Check for formatting code: X<...> or X<< ... >> where X is a POD code.
311        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; // skip X and the opening angle delimiter
319
320            let start = i;
321            let end = if delimiter_width == 1 {
322                // Find matching > accounting for nested <> in classic X<...> codes.
323                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; // skip >
337                }
338                end
339            } else {
340                // POD permits doubled (or wider) delimiters so content can contain raw
341                // angle brackets, for example C<< $obj->method >>.
342                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
388/// Percent-encode characters that are invalid in a markdown link URL.
389///
390/// Encodes spaces (most common in POD section names like `L<Module/"Section Name">`)
391/// and other characters that would break the markdown `[text](url)` parser.
392fn 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
418/// Extract a markdown link from a POD `L<>` formatting code.
419///
420/// Returns `[display](perl-module://target)` so LSP clients (VS Code) render
421/// the link as clickable in hover tooltips.  Spaces in section names are
422/// percent-encoded so the URL is well-formed.
423///
424/// Handles all standard POD link forms:
425/// - `L<Module::Name>` → `[Module::Name](perl-module://Module::Name)`
426/// - `L<text|Module::Name>` → `[text](perl-module://Module::Name)`
427/// - `L<Module::Name/section>` → `[Module::Name](perl-module://Module::Name/section)`
428/// - `L<text|Module::Name/section>` → `[text](perl-module://Module::Name/section)`
429fn extract_link_display(link: &str, depth: usize) -> String {
430    // L<text|target> — explicit display text before the pipe
431    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    // L<Module/section> — module + section, display is just the module part
438    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    // L<Module::Name> — simple module reference
445    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
450/// Decodes a POD E<> entity to its corresponding character.
451///
452/// Handles standard POD escape sequences:
453/// - `E<lt>` → `<`
454/// - `E<gt>` → `>`
455/// - `E<amp>` → `&`
456/// - `E<quot>` → `"`
457/// - `E<apos>` → `'`
458///
459/// - `E<sol>` -> `/`
460/// - `E<verbar>` -> `|`
461/// - `E<number>`, `E<0xhex>`, and `E<0octal>` numeric codepoints
462///
463/// Unknown entities are returned as-is.
464fn 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    // ── decode_pod_entity ────────────────────────────────────────────────────
517
518    #[test]
519    fn decode_entity_unknown_returns_name_unchanged() {
520        // Unknown entity names fall through to the `_` branch and are returned as-is.
521        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        // Empty entity name is unknown → returned unchanged (empty string).
529        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    // ── multi-angle POD formatting delimiters ────────────────────────────────
562
563    #[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    // ── L<> display-text trimming (issues #2480, #2482, #2485) ───────────────
582
583    #[test]
584    fn link_pipe_form_trims_display_and_target() {
585        // L<text|target> — leading/trailing whitespace on both sides is trimmed
586        // so neither the display text nor the target leaks padding (#2480).
587        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        // L<Module/section> — the module display part is trimmed so no trailing
593        // space leaks into the rendered link text (#2482).
594        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        // L<Module::Name> — surrounding whitespace is trimmed from the display
603        // text (#2485).
604        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        // Regression for unbounded recursion: ~5000 nested B<I<...>> formatting
620        // codes previously blew the stack. With MAX_POD_FORMATTING_DEPTH the call
621        // returns without panicking; content past the cap is emitted verbatim.
622        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        // Must return normally (no stack overflow / panic).
629        let stripped = strip_pod_formatting(&text);
630
631        // The innermost payload survives the strip.
632        assert!(stripped.contains("core"), "expected innermost content to remain");
633        // Past the depth cap, residual unstripped delimiters may remain, so the
634        // result is not guaranteed to be exactly "core"; the contract here is
635        // simply that the function terminates safely.
636    }
637
638    #[test]
639    fn extract_pod_deeply_nested_head2_does_not_overflow_stack() {
640        // Exercise the public reachability path (=head2 → strip_pod_formatting).
641        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        // Must not overflow the stack.
649        let doc = extract_pod(&source);
650
651        assert_eq!(doc.methods.len(), 1, "expected exactly one method section");
652    }
653
654    // ── encode_pod_link_target ───────────────────────────────────────────────
655
656    #[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        // The safe set includes alphanumerics and: - . _ ~ : /
664        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        // "café" — 'é' is U+00E9, encoded in UTF-8 as 0xC3 0xA9.
686        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        // "日本語" — each kanji is three UTF-8 bytes.
693        let result = encode_pod_link_target("日本語");
694        // 日 = E6 97 A5, 本 = E6 9C AC, 語 = E8 AA 9E
695        assert_eq!(result, "%E6%97%A5%E6%9C%AC%E8%AA%9E");
696    }
697
698    #[test]
699    fn encode_link_consecutive_special_chars() {
700        // Multiple consecutive non-safe characters are each percent-encoded.
701        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        // Tab (0x09) and newline (0x0A) are not in the safe set → percent-encoded.
708        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}