Skip to main content

sphinx_ultra/
utils.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6/// [`std::fs::canonicalize`] with the Windows verbatim prefix taken back
7/// off ([`simplify_verbatim`]) — the spelling every path comparison,
8/// display and `%r` in this crate speaks.
9///
10/// EVERY canonicalization in the tree (its tests included) goes through
11/// here: the prefix has to be present on all sides of a comparison or on
12/// none, and "none" is what Python produces, so "none" it is.
13pub fn canonicalize_simplified(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
14    std::fs::canonicalize(path).map(simplify_verbatim)
15}
16
17/// Drop the `\\?\` verbatim prefix that Windows' `GetFinalPathNameByHandle`
18/// — and so [`std::fs::canonicalize`] — puts in front of every canonical
19/// path.
20///
21/// Python's `pathlib.Path.resolve()` and `os.path.realpath()` return the
22/// plain `C:\dir\file` spelling, so the verbatim form is a byte-divergence
23/// in every message that prints a resolved path (`_StrPath(...)` reprs,
24/// `Include file '...'`, `:diff:` headers). It is also a FUNCTIONAL
25/// hazard: inside a verbatim path Windows does no normalization at all —
26/// `/` is an ordinary filename character there and `..` is not resolved —
27/// so a lexically joined `\\?\C:\src` + `sub/inner.rst` names nothing.
28///
29/// `\\?\UNC\server\share` maps back to `\\server\share`; a path long
30/// enough that the prefix is what makes it openable at all keeps it (the
31/// 260-character `MAX_PATH` limit, which the plain spelling is only exempt
32/// from with a per-application opt-in this crate does not make). A
33/// non-Windows path matches neither shape, so this is the identity
34/// function there.
35pub fn simplify_verbatim(path: PathBuf) -> PathBuf {
36    let simplified = match path.to_str() {
37        Some(text) => match simplify_verbatim_str(text) {
38            Cow::Borrowed(unchanged) if unchanged.len() == text.len() => None,
39            simplified => Some(simplified.into_owned()),
40        },
41        None => None,
42    };
43    match simplified {
44        Some(text) => PathBuf::from(text),
45        None => path,
46    }
47}
48
49/// The string half of [`simplify_verbatim`], so the rule can be tested
50/// with Windows-shaped literals on every platform.
51fn simplify_verbatim_str(text: &str) -> Cow<'_, str> {
52    /// `MAX_PATH`: at this length the plain spelling stops being openable,
53    /// so the verbatim prefix stays on.
54    const MAX_PATH: usize = 260;
55
56    if let Some(share) = text.strip_prefix(r"\\?\UNC\") {
57        let plain = format!(r"\\{share}");
58        if plain.len() < MAX_PATH {
59            return Cow::Owned(plain);
60        }
61        return Cow::Borrowed(text);
62    }
63    if let Some(rest) = text.strip_prefix(r"\\?\") {
64        // Only `X:\...` survives the round trip; the other verbatim shapes
65        // (`\\?\Volume{...}`, a device path) have no plain spelling.
66        let mut head = rest.chars();
67        let drive = matches!(
68            (head.next(), head.next(), head.next()),
69            (Some(letter), Some(':'), Some('\\')) if letter.is_ascii_alphabetic()
70        );
71        if drive && rest.len() < MAX_PATH {
72            return Cow::Borrowed(rest);
73        }
74    }
75    Cow::Borrowed(text)
76}
77
78/// `BuildEnvironment.relfn2path` (`environment/__init__.py:454-478`): a
79/// filename written in a document resolves relative to that document's
80/// directory, unless it is written absolute (`/pic.png`), in which case it
81/// is relative to the source directory. The result is normalized (`.` and
82/// `..` collapsed, Sphinx's `os.path.normpath`) and joined onto srcdir.
83///
84/// Shared home (wave 4.5): the image dependency collector
85/// ([`crate::env::dependencies`]) and the `include` directive's sphinx-mode
86/// path rewrite (`sphinx/directives/other.py:413-416`) both resolve through
87/// this port.
88pub fn relfn2path(uri: &str, docname: &str, srcdir: &Path) -> PathBuf {
89    let mut path = srcdir.to_path_buf();
90    for segment in relfn2path_rel(uri, docname).split('/') {
91        if !segment.is_empty() {
92            path.push(segment);
93        }
94    }
95    path
96}
97
98/// The path to actually OPEN for `uri` written in `docname`.
99///
100/// Sphinx's `relfn2path` calls `.resolve()` on the joined path
101/// (`environment/__init__.py:466`/`:475`), so symlinks are followed BEFORE
102/// any `..` is interpreted. [`relfn2path`] instead collapses `..`
103/// lexically, which under a symlinked directory — `docs/examples ->
104/// ../../examples` and an `include` of `examples/../shared.txt` — names a
105/// DIFFERENT FILE: sphinx walks up from the link's target, the lexical
106/// rule walks up from the link's own parent.
107///
108/// The two are separate functions on purpose. §Scope-8 fixes the
109/// srcdir-relative spelling every path-bearing surface of included
110/// content shows, so [`relfn2path`] keeps feeding those; only the read
111/// goes through here.
112pub fn relfn2path_io(uri: &str, docname: &str, srcdir: &Path) -> PathBuf {
113    let mut path = srcdir.to_path_buf();
114    for segment in relfn2path_join(uri, docname).split('/') {
115        if !segment.is_empty() {
116            path.push(segment);
117        }
118    }
119    resolve_path(&path)
120}
121
122/// The join `relfn2path` does before resolving — `srcdir.joinpath(doc_dir,
123/// file_name)` — with the `.`/`..` segments still IN. Sphinx collapses
124/// them inside `.resolve()`, i.e. AFTER symlinks, so they must not be
125/// collapsed here.
126fn relfn2path_join(uri: &str, docname: &str) -> String {
127    match uri.strip_prefix('/') {
128        Some(rooted) => rooted.to_string(),
129        None => match docname.rsplit_once('/') {
130            Some((dir, _)) => format!("{dir}/{uri}"),
131            None => uri.to_string(),
132        },
133    }
134}
135
136/// `pathlib.Path.resolve()` with `strict=False`, which is what
137/// `relfn2path` calls: symlinks are followed component by component and
138/// `..` is applied to what is already RESOLVED, so a path leaving a
139/// symlinked directory lands beside the link's target, not beside the
140/// link. Components past the last existing one cannot be followed and
141/// collapse lexically, exactly as `os.path.realpath(strict=False)` does —
142/// which is how a not-yet-existing include target still normalizes.
143pub(crate) fn resolve_path(path: &Path) -> PathBuf {
144    use std::path::Component;
145    let mut resolved = PathBuf::new();
146    for component in path.components() {
147        match component {
148            Component::Prefix(_) | Component::RootDir => resolved.push(component),
149            Component::CurDir => {}
150            Component::ParentDir => {
151                resolved.pop();
152            }
153            Component::Normal(name) => {
154                resolved.push(name);
155                if let Ok(real) = canonicalize_simplified(&resolved) {
156                    resolved = real;
157                }
158            }
159        }
160    }
161    resolved
162}
163
164/// The srcdir-relative half of [`relfn2path`]: the normalized posix path
165/// (relative to the source directory) that `uri` written in `docname`
166/// refers to. Sphinx's `rel_fn` return value.
167pub fn relfn2path_rel(uri: &str, docname: &str) -> String {
168    let relative = match uri.strip_prefix('/') {
169        Some(rooted) => rooted.to_string(),
170        None => match docname.rsplit_once('/') {
171            Some((dir, _)) => format!("{dir}/{uri}"),
172            None => uri.to_string(),
173        },
174    };
175    normalize_dot_segments(&relative)
176}
177
178/// `os.path.normpath` over a posix-separated relative path: `.` and inner
179/// `..` collapse; a leading `..` stays and walks out of the tree (a path
180/// that simply will not exist).
181pub fn normalize_dot_segments(relative: &str) -> String {
182    let mut segments: Vec<&str> = Vec::new();
183    for segment in relative.split('/') {
184        match segment {
185            "" | "." => {}
186            ".." => {
187                // `normpath` only drops a `..` that has something to undo.
188                if matches!(segments.last(), Some(&last) if last != "..") {
189                    segments.pop();
190                } else {
191                    segments.push("..");
192                }
193            }
194            other => segments.push(other),
195        }
196    }
197    segments.join("/")
198}
199
200/// `Path.relative_to(root, walk_up=True)` over two already-resolved
201/// absolute paths — the second half of sphinx's `relfn2path`
202/// (`_relative_path(abs_fn, self.srcdir)`, `util/osutil.py:173-189`):
203/// the srcdir-relative spelling of a file, walking UP with `..` when the
204/// file lies outside the source directory (`srcdir / "../ext/part.rst"`
205/// is exactly what `note_dependency` then stores). Posix-separated.
206///
207/// Both inputs must be resolved ([`resolve_path`]), like sphinx's — a
208/// symlink followed on one side only would make the walk-up lie. Paths on
209/// different roots (Windows drives) have no relative spelling; sphinx
210/// returns the path itself, and so does this.
211pub(crate) fn relative_path_walk_up(path: &Path, root: &Path) -> String {
212    use std::path::Component;
213    let path_parts: Vec<Component<'_>> = path.components().collect();
214    let root_parts: Vec<Component<'_>> = root.components().collect();
215    let anchors_differ = match (path_parts.first(), root_parts.first()) {
216        (Some(Component::Prefix(a)), Some(Component::Prefix(b))) => a != b,
217        (Some(Component::Prefix(_)), _) | (_, Some(Component::Prefix(_))) => true,
218        _ => false,
219    };
220    if anchors_differ {
221        return path.to_string_lossy().replace('\\', "/");
222    }
223    let common = path_parts
224        .iter()
225        .zip(root_parts.iter())
226        .take_while(|(a, b)| a == b)
227        .count();
228    let mut segments: Vec<String> = vec!["..".to_string(); root_parts.len() - common];
229    segments.extend(
230        path_parts[common..]
231            .iter()
232            .map(|c| c.as_os_str().to_string_lossy().into_owned()),
233    );
234    segments.join("/")
235}
236
237/// Python `str.isspace()`, which is also what `re`'s `\s` matches on a
238/// `str` pattern: the Unicode White_Space set ([`char::is_whitespace`])
239/// PLUS the four C0 separators `\x1c`-`\x1f` (bidirectional class B/S,
240/// which Unicode does not call whitespace). `\x1c`-`\x1e` are also
241/// [`py_splitlines`] boundaries; `\x1f` is the one that survives into a
242/// line and shows the difference.
243pub(crate) fn py_isspace(c: char) -> bool {
244    c.is_whitespace() || matches!(c, '\x1c'..='\x1f')
245}
246
247/// `repr()` of a Python `str` (CPython `unicode_repr`): single quotes
248/// unless the text has a `'` and no `"`; `\\`, `\n`, `\r`, `\t` and the
249/// chosen quote backslash-escaped; and every character
250/// `str.isprintable()` rejects rendered as `\xNN` / `\uNNNN` / `\UNNNNNNNN`
251/// (lowercase hex). That set is the categories Cc, Cf, Cs, Co, Cn, Zl, Zp
252/// and Zs minus the ASCII space: `is_control()` is exactly Cc and
253/// `is_whitespace()` is exactly Zs|Zl|Zp plus Cc members, so the predicate
254/// below is those five categories precisely, and Cs cannot exist in a Rust
255/// `char`. **Cf, Co and Cn are the ledgered gap** — matching them needs a
256/// Unicode general-category table this tree does not have — recorded in
257/// docs/IMPLEMENTATION_STATUS.md and nowhere else; this is the ONE
258/// implementation (panel fix round F), behind `src/rst/block.rs`'s
259/// `py_repr` for directive messages and index-entry tuples and behind
260/// every warning-stream `%r` (toctree, resolver, py domain, intersphinx,
261/// builder).
262pub(crate) fn py_repr_str(s: &str) -> String {
263    let quote = if s.contains('\'') && !s.contains('"') {
264        '"'
265    } else {
266        '\''
267    };
268    let mut out = String::with_capacity(s.len() + 2);
269    out.push(quote);
270    for c in s.chars() {
271        match c {
272            '\\' => out.push_str("\\\\"),
273            '\n' => out.push_str("\\n"),
274            '\r' => out.push_str("\\r"),
275            '\t' => out.push_str("\\t"),
276            c if c == quote => {
277                out.push('\\');
278                out.push(c);
279            }
280            c if c != ' ' && (c.is_control() || c.is_whitespace()) => {
281                let n = c as u32;
282                if n <= 0xff {
283                    out.push_str(&format!("\\x{n:02x}"));
284                } else if n <= 0xffff {
285                    out.push_str(&format!("\\u{n:04x}"));
286                } else {
287                    out.push_str(&format!("\\U{n:08x}"));
288                }
289            }
290            c => out.push(c),
291        }
292    }
293    out.push(quote);
294    out
295}
296
297/// `urllib.parse.quote(s)` with its default `safe='/'`: every byte of the
298/// UTF-8 encoding outside `A-Za-z0-9`, `_.-~` and `/` becomes `%XX`
299/// (upper-case hex). `StandaloneHTMLBuilder.get_target_uri` applies it to
300/// a docname before appending the link suffix, so `my page` links as
301/// `my%20page.html` and reads back as one token in an inventory line.
302pub fn quote_url_path(s: &str) -> String {
303    let mut out = String::with_capacity(s.len());
304    for byte in s.bytes() {
305        match byte {
306            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' | b'/' => {
307                out.push(byte as char)
308            }
309            _ => out.push_str(&format!("%{byte:02X}")),
310        }
311    }
312    out
313}
314
315/// Python `str.split()` with no separator: split on runs of [`py_isspace`],
316/// dropping the empty leading/trailing/interior fields. Rust's
317/// `str::split_whitespace` is the same shape over a NARROWER set (it misses
318/// the C0 separators `\x1c`-`\x1f`), so every port of a docutils/Sphinx
319/// `.split()` must come through here.
320pub(crate) fn py_split(s: &str) -> impl Iterator<Item = &str> {
321    s.split(py_isspace).filter(|w| !w.is_empty())
322}
323
324/// `Project.path2doc` (`sphinx/project.py:114-128`) against Sphinx's
325/// *default* `source_suffix` — `{'.rst': 'restructuredtext'}`
326/// (`config.py:243`): the docname a source file under `srcdir` maps to, or
327/// `None` for anything else.
328///
329/// Deliberately narrower than this crate's discovery (which also admits
330/// `.md`/`.txt`): the consumers — `env.included` bookkeeping and the orphan
331/// check behind it — must match what Sphinx records, and Sphinx's default
332/// project never maps a `.txt` include target to a document.
333///
334/// One knowing simplification: for a `.rst` OUTSIDE `srcdir` sphinx's
335/// `relative_to` fails and the absolute path itself becomes the "docname"
336/// (`'/base/ext/part'`, probed) — a name no document can have, so the
337/// orphan check ignores it. `None` here has the same effect, and keeps
338/// `env.included` free of environment-specific absolute paths.
339pub fn path2doc(path: &Path, srcdir: &Path) -> Option<String> {
340    let rel = path.strip_prefix(srcdir).ok()?;
341    let rel = rel.to_str()?.replace('\\', "/");
342    Some(rel.strip_suffix(".rst")?.to_string())
343}
344
345#[derive(Debug)]
346pub struct ProjectStats {
347    pub source_files: usize,
348    pub total_lines: usize,
349    pub avg_file_size_kb: f64,
350    pub largest_file_kb: f64,
351    pub max_depth: usize,
352    pub cross_references: usize,
353}
354
355pub async fn analyze_project(source_dir: &Path) -> Result<ProjectStats> {
356    let mut state = AnalysisState {
357        source_files: 0,
358        total_lines: 0,
359        total_size_bytes: 0,
360        largest_file_kb: 0.0,
361        max_depth: 0,
362        cross_references: 0,
363    };
364
365    // Use synchronous approach to avoid async recursion issues
366    analyze_directory_sync(source_dir, source_dir, 0, &mut state)?;
367
368    let avg_file_size_kb = if state.source_files > 0 {
369        (state.total_size_bytes as f64) / (state.source_files as f64) / 1024.0
370    } else {
371        0.0
372    };
373
374    Ok(ProjectStats {
375        source_files: state.source_files,
376        total_lines: state.total_lines,
377        avg_file_size_kb,
378        largest_file_kb: state.largest_file_kb,
379        max_depth: state.max_depth,
380        cross_references: state.cross_references,
381    })
382}
383
384/// Analysis state for directory traversal
385struct AnalysisState {
386    source_files: usize,
387    total_lines: usize,
388    total_size_bytes: u64,
389    largest_file_kb: f64,
390    max_depth: usize,
391    cross_references: usize,
392}
393
394fn analyze_directory_sync(
395    dir: &Path,
396    _root_dir: &Path,
397    current_depth: usize,
398    state: &mut AnalysisState,
399) -> Result<()> {
400    state.max_depth = state.max_depth.max(current_depth);
401
402    for entry in std::fs::read_dir(dir)? {
403        let entry = entry?;
404        let path = entry.path();
405
406        if path.is_dir() {
407            // Skip hidden directories
408            if let Some(name) = path.file_name() {
409                if name.to_string_lossy().starts_with('.') {
410                    continue;
411                }
412            }
413
414            analyze_directory_sync(&path, _root_dir, current_depth + 1, state)?;
415        } else if is_source_file(&path) {
416            state.source_files += 1;
417
418            let metadata = std::fs::metadata(&path)?;
419            let file_size_bytes = metadata.len();
420            let file_size_kb = file_size_bytes as f64 / 1024.0;
421
422            state.total_size_bytes += file_size_bytes;
423            state.largest_file_kb = state.largest_file_kb.max(file_size_kb);
424
425            // Count lines and cross-references
426            if let Ok(content) = std::fs::read_to_string(&path) {
427                state.total_lines += content.lines().count();
428                state.cross_references += count_cross_references(&content);
429            }
430        }
431    }
432
433    Ok(())
434}
435
436pub fn is_source_file(path: &Path) -> bool {
437    if let Some(ext) = path.extension() {
438        matches!(ext.to_string_lossy().as_ref(), "rst" | "md" | "txt")
439    } else {
440        false
441    }
442}
443
444pub fn count_cross_references(content: &str) -> usize {
445    let patterns = [
446        r":doc:`",
447        r":ref:`",
448        r":func:`",
449        r":class:`",
450        r":meth:`",
451        r":attr:`",
452        r":mod:`",
453        r":py:",
454        r".. _",
455        r"`~",
456    ];
457
458    let mut count = 0;
459    for pattern in &patterns {
460        count += content.matches(pattern).count();
461    }
462    count
463}
464
465pub fn get_file_mtime(path: &Path) -> Result<DateTime<Utc>> {
466    let metadata = std::fs::metadata(path)?;
467    let mtime = metadata.modified()?;
468    Ok(DateTime::from(mtime))
469}
470
471pub async fn calculate_directory_size(dir: &Path) -> Result<u64> {
472    // Use synchronous approach
473    calculate_directory_size_sync(dir)
474}
475
476fn calculate_directory_size_sync(dir: &Path) -> Result<u64> {
477    let mut total_size = 0;
478
479    for entry in std::fs::read_dir(dir)? {
480        let entry = entry?;
481        let path = entry.path();
482
483        if path.is_dir() {
484            total_size += calculate_directory_size_sync(&path)?;
485        } else {
486            let metadata = std::fs::metadata(&path)?;
487            total_size += metadata.len();
488        }
489    }
490
491    Ok(total_size)
492}
493
494pub async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
495    // Use synchronous approach
496    copy_dir_recursive_sync(src, dst)
497}
498
499fn copy_dir_recursive_sync(src: &Path, dst: &Path) -> Result<()> {
500    std::fs::create_dir_all(dst)?;
501
502    for entry in std::fs::read_dir(src)? {
503        let entry = entry?;
504        let src_path = entry.path();
505        let dst_path = dst.join(entry.file_name());
506
507        if src_path.is_dir() {
508            copy_dir_recursive_sync(&src_path, &dst_path)?;
509        } else {
510            std::fs::copy(&src_path, &dst_path)?;
511        }
512    }
513
514    Ok(())
515}
516
517#[allow(dead_code)]
518pub fn format_duration(duration: std::time::Duration) -> String {
519    let secs = duration.as_secs();
520    let millis = duration.subsec_millis();
521
522    if secs > 0 {
523        format!("{}.{:03}s", secs, millis)
524    } else {
525        format!("{}ms", millis)
526    }
527}
528
529#[allow(dead_code)]
530pub fn format_bytes(bytes: u64) -> String {
531    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
532
533    if bytes == 0 {
534        return "0 B".to_string();
535    }
536
537    let mut size = bytes as f64;
538    let mut unit_index = 0;
539
540    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
541        size /= 1024.0;
542        unit_index += 1;
543    }
544
545    format!("{:.1} {}", size, UNITS[unit_index])
546}
547
548/// Format a date according to the specified format string and language
549#[allow(dead_code)]
550pub fn format_date(fmt: &str, _language: &Option<String>) -> String {
551    let now = chrono::Utc::now();
552
553    match fmt {
554        "%b %d, %Y" => now.format("%b %d, %Y").to_string(),
555        "%B %d, %Y" => now.format("%B %d, %Y").to_string(),
556        "%Y-%m-%d" => now.format("%Y-%m-%d").to_string(),
557        "%Y-%m-%d %H:%M:%S" => now.format("%Y-%m-%d %H:%M:%S").to_string(),
558        _ => {
559            // For custom formats, try to parse and format
560            match chrono::DateTime::parse_from_str(&now.to_rfc3339(), "%+") {
561                Ok(dt) => dt.format(fmt).to_string(),
562                Err(_) => now.format("%Y-%m-%d").to_string(),
563            }
564        }
565    }
566}
567
568/// Ensure a directory exists, creating it if necessary
569#[allow(dead_code)]
570pub async fn ensure_dir(path: &Path) -> Result<()> {
571    use tokio::fs;
572
573    if !path.exists() {
574        fs::create_dir_all(path).await?;
575    }
576    Ok(())
577}
578
579/// Calculate relative URI from one path to another
580#[allow(dead_code)]
581pub fn relative_uri(from: &str, to: &str, suffix: &str) -> String {
582    use std::path::Path;
583
584    let from_path = Path::new(from);
585    let to_path = Path::new(to);
586
587    // Get the relative path
588    if let Some(rel_path) =
589        pathdiff::diff_paths(to_path, from_path.parent().unwrap_or(Path::new("")))
590    {
591        let mut result = rel_path.to_string_lossy().to_string();
592        if !suffix.is_empty() && !result.ends_with(suffix) {
593            result.push_str(suffix);
594        }
595        result.replace('\\', "/") // Ensure forward slashes
596    } else {
597        format!("{}{}", to, suffix)
598    }
599}
600
601/// Copy all files and directories from source to destination
602#[allow(dead_code)]
603pub async fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
604    use tokio::fs;
605
606    ensure_dir(dst).await?;
607
608    let mut entries = fs::read_dir(src).await?;
609
610    while let Some(entry) = entries.next_entry().await? {
611        let entry_path = entry.path();
612        let file_name = entry.file_name();
613        let dest_path = dst.join(file_name);
614
615        if entry_path.is_dir() {
616            Box::pin(copy_dir_all(&entry_path, &dest_path)).await?;
617        } else {
618            if let Some(parent) = dest_path.parent() {
619                ensure_dir(parent).await?;
620            }
621            fs::copy(&entry_path, &dest_path).await?;
622        }
623    }
624
625    Ok(())
626}
627
628/// Python `str.splitlines()`: the full boundary set (`\n`, `\r`, `\r\n`,
629/// `\v`, `\f`, `\x1c`-`\x1e`, `\u{85}`, `\u{2028}`, `\u{2029}`), no
630/// trailing empty line for a terminal boundary.
631///
632/// Shared home: the block parser's line handling and
633/// [`crate::doctree::pformat`]'s `Text.pformat` port both need it.
634pub(crate) fn py_splitlines(text: &str) -> Vec<&str> {
635    let is_boundary = |c: char| {
636        matches!(
637            c,
638            '\n' | '\r'
639                | '\x0b'
640                | '\x0c'
641                | '\x1c'
642                | '\x1d'
643                | '\x1e'
644                | '\u{85}'
645                | '\u{2028}'
646                | '\u{2029}'
647        )
648    };
649    let mut out = Vec::new();
650    let mut start = 0usize;
651    let mut chars = text.char_indices().peekable();
652    while let Some((i, c)) = chars.next() {
653        if is_boundary(c) {
654            out.push(&text[start..i]);
655            if c == '\r' {
656                if let Some(&(_, '\n')) = chars.peek() {
657                    chars.next();
658                }
659            }
660            start = chars.peek().map(|&(j, _)| j).unwrap_or(text.len());
661        }
662    }
663    if start < text.len() {
664        out.push(&text[start..]);
665    }
666    out
667}
668
669#[cfg(test)]
670mod path_tests {
671    use super::*;
672
673    #[test]
674    fn relfn2path_rel_resolves_docname_relative_and_rooted_forms() {
675        assert_eq!(
676            relfn2path_rel("part.rst", "chapters/intro"),
677            "chapters/part.rst"
678        );
679        assert_eq!(
680            relfn2path_rel("/sub/abs.rst", "chapters/intro"),
681            "sub/abs.rst"
682        );
683        assert_eq!(
684            relfn2path_rel("../img/./pic.png", "chapters/intro"),
685            "img/pic.png"
686        );
687        assert_eq!(relfn2path_rel("x.rst", "index"), "x.rst");
688        // A leading `..` walks out of the tree and stays.
689        assert_eq!(relfn2path_rel("../outside.rst", "index"), "../outside.rst");
690    }
691
692    #[test]
693    fn relfn2path_joins_the_rel_half_onto_srcdir() {
694        assert_eq!(
695            relfn2path("part.rst", "chapters/intro", Path::new("/src")),
696            PathBuf::from("/src/chapters/part.rst")
697        );
698    }
699
700    /// Sphinx's default `source_suffix` is `.rst` alone; this crate's wider
701    /// discovery (`.md`/`.txt`) deliberately does not leak into the
702    /// `env.included` bookkeeping this helper feeds.
703    #[test]
704    fn path2doc_maps_rst_under_srcdir_and_nothing_else() {
705        let srcdir = Path::new("/src");
706        assert_eq!(
707            path2doc(Path::new("/src/part.rst"), srcdir),
708            Some("part".into())
709        );
710        assert_eq!(
711            path2doc(Path::new("/src/sub/abs_part.rst"), srcdir),
712            Some("sub/abs_part".into())
713        );
714        assert_eq!(path2doc(Path::new("/src/data.txt"), srcdir), None);
715        assert_eq!(path2doc(Path::new("/src/notes.md"), srcdir), None);
716        assert_eq!(path2doc(Path::new("/elsewhere/part.rst"), srcdir), None);
717    }
718
719    /// `_relative_path(abs_fn, srcdir)`: inside the tree it is the plain
720    /// relative spelling; outside it walks up with `..`, which is what
721    /// makes `srcdir / rel` name the file sphinx actually read.
722    ///
723    // oracle (sphinx 9.1.0, probe_symlink.py s1/s5): an include resolving
724    // to BASE/ext/part.rst from srcdir BASE/src records
725    // env.dependencies == {'index': {BASE/'src/../ext/part.rst'}}.
726    #[test]
727    fn relative_path_walk_up_matches_sphinxs_relative_to() {
728        let root = Path::new("/base/src");
729        assert_eq!(
730            relative_path_walk_up(Path::new("/base/src/a/c.rst"), root),
731            "a/c.rst"
732        );
733        assert_eq!(
734            relative_path_walk_up(Path::new("/base/ext/part.rst"), root),
735            "../ext/part.rst"
736        );
737        assert_eq!(
738            relative_path_walk_up(Path::new("/other/x.txt"), root),
739            "../../other/x.txt"
740        );
741        assert_eq!(relative_path_walk_up(Path::new("/base/src"), root), "");
742    }
743
744    /// Python's `isspace` set is Unicode White_Space plus `\x1c`-`\x1f`.
745    #[test]
746    fn py_isspace_is_unicode_whitespace_plus_the_c0_separators() {
747        for c in [
748            ' ', '\t', '\n', '\u{a0}', '\u{3000}', '\x1c', '\x1d', '\x1e', '\x1f',
749        ] {
750            assert!(py_isspace(c), "{c:?}");
751        }
752        for c in ['a', '\x00', '\x1b', '\u{200b}'] {
753            assert!(!py_isspace(c), "{c:?}");
754        }
755        assert!(!'\x1f'.is_whitespace(), "the case Rust's predicate misses");
756    }
757
758    /// CPython 3.12 `repr()`: quote choice, the four named escapes, and
759    /// `\xNN`/`\uNNNN` for every non-`str.isprintable()` character —
760    /// probed (`repr('term\xa0')` → `'term\xa0'`, `repr('a\u3000b')` →
761    /// `'a\u3000b'`, `repr('a\x85b')` → `'a\x85b'`; panel fix round F).
762    #[test]
763    fn py_repr_str_quotes_and_escapes_like_cpython() {
764        assert_eq!(py_repr_str("a"), "'a'");
765        assert_eq!(py_repr_str("it's"), "\"it's\"");
766        assert_eq!(py_repr_str("say \"hi\""), "'say \"hi\"'");
767        assert_eq!(py_repr_str("both ' and \""), "'both \\' and \"'");
768        assert_eq!(py_repr_str("a\\b"), "'a\\\\b'");
769        assert_eq!(py_repr_str("a\nb"), "'a\\nb'");
770        assert_eq!(py_repr_str("a\tb\rc"), "'a\\tb\\rc'");
771        assert_eq!(py_repr_str("term\u{a0}"), "'term\\xa0'");
772        assert_eq!(py_repr_str("foo\u{a0}bar"), "'foo\\xa0bar'");
773        assert_eq!(py_repr_str("a\u{3000}b"), "'a\\u3000b'");
774        assert_eq!(py_repr_str("a\u{85}b"), "'a\\x85b'");
775        assert_eq!(py_repr_str("a\x1fb\x7f"), "'a\\x1fb\\x7f'");
776        assert_eq!(py_repr_str("a\u{2028}b"), "'a\\u2028b'");
777        assert_eq!(py_repr_str("é ü"), "'é ü'", "printable non-ASCII stays raw");
778    }
779
780    /// `urllib.parse.quote` with the default `safe='/'`, probed on CPython
781    /// 3.12: `quote('my page')` → `'my%20page'`, `quote('sub/café')` →
782    /// `'sub/caf%C3%A9'`, `quote('a+b&c=d~e')` → `'a%2Bb%26c%3Dd~e'`.
783    #[test]
784    fn quote_url_path_matches_urllib_quote() {
785        assert_eq!(quote_url_path("my page"), "my%20page");
786        assert_eq!(quote_url_path("sub/café"), "sub/caf%C3%A9");
787        assert_eq!(quote_url_path("a+b&c=d~e"), "a%2Bb%26c%3Dd~e");
788        assert_eq!(quote_url_path("plain_name-1.2"), "plain_name-1.2");
789    }
790
791    /// [`simplify_verbatim`] over Windows-shaped literals, which is the
792    /// only way to exercise the rule off Windows (`canonicalize` there
793    /// hands back exactly these shapes). The plain spelling is what
794    /// `pathlib.Path.resolve()` returns, and the only one Windows
795    /// normalizes `/` and `..` inside.
796    #[test]
797    fn the_verbatim_prefix_is_stripped_back_to_the_python_spelling() {
798        let simplify = |text: &str| {
799            simplify_verbatim(PathBuf::from(text))
800                .to_string_lossy()
801                .into_owned()
802        };
803        assert_eq!(simplify(r"\\?\C:\Users\me\docs"), r"C:\Users\me\docs");
804        assert_eq!(simplify(r"\\?\c:\x"), r"c:\x");
805        assert_eq!(simplify(r"\\?\UNC\server\share\doc"), r"\\server\share\doc");
806        // Not a drive path: no plain spelling exists, so it keeps the
807        // prefix rather than becoming unopenable.
808        assert_eq!(simplify(r"\\?\Volume{9f8a}\x"), r"\\?\Volume{9f8a}\x");
809        // Past MAX_PATH the prefix is what makes the path openable.
810        let long = format!(r"\\?\C:\{}", "a".repeat(300));
811        assert_eq!(simplify(&long), long);
812        // Everything else — every POSIX path included — is untouched.
813        assert_eq!(simplify("/tmp/x/y"), "/tmp/x/y");
814        assert_eq!(simplify(r"C:\already\plain"), r"C:\already\plain");
815        assert_eq!(simplify(r"\\server\share"), r"\\server\share");
816    }
817
818    /// Sphinx `.resolve()`s the joined path, so `..` walks up from a
819    /// symlink's TARGET, not from the link's own parent. The lexical
820    /// collapse [`relfn2path`] keeps for §Scope-8 display spellings gets
821    /// this wrong, which is why the read goes through
822    /// [`relfn2path_io`].
823    ///
824    // oracle: sphinx/environment/__init__.py:475
825    //   `abs_fn = self.srcdir.joinpath(doc_dir, file_name).resolve()`
826    //   (probed: with BASE/src/link -> BASE/ext, a literalinclude of
827    //   `link/../secret.txt` reads BASE/ext/../secret.txt = BASE/secret.txt,
828    //   not BASE/src/secret.txt).
829    #[test]
830    fn relfn2path_io_walks_up_from_the_symlink_target() {
831        let base = tempfile::tempdir().unwrap();
832        let base = canonicalize_simplified(base.path()).unwrap();
833        let srcdir = base.join("src");
834        std::fs::create_dir_all(srcdir.join("real")).unwrap();
835        std::fs::create_dir_all(base.join("ext/inner")).unwrap();
836        std::fs::write(base.join("ext/sibling.txt"), "OUTSIDE\n").unwrap();
837        std::fs::write(srcdir.join("sibling.txt"), "INSIDE\n").unwrap();
838        #[cfg(unix)]
839        std::os::unix::fs::symlink(base.join("ext/inner"), srcdir.join("link")).unwrap();
840
841        // Lexically, `link/../sibling.txt` is `sibling.txt` under srcdir.
842        assert_eq!(
843            relfn2path("link/../sibling.txt", "index", &srcdir),
844            srcdir.join("sibling.txt")
845        );
846        // Resolved, `link` is `<base>/ext/inner`, so `..` lands in
847        // `<base>/ext` — a different file entirely.
848        #[cfg(unix)]
849        assert_eq!(
850            relfn2path_io("link/../sibling.txt", "index", &srcdir),
851            base.join("ext/sibling.txt")
852        );
853
854        // A path with no symlink in it is unchanged by the resolve, and a
855        // target that does not exist yet still normalizes.
856        assert_eq!(
857            relfn2path_io("real/../sibling.txt", "index", &srcdir),
858            srcdir.join("sibling.txt")
859        );
860        assert_eq!(
861            relfn2path_io("real/../nothere.txt", "index", &srcdir),
862            srcdir.join("nothere.txt")
863        );
864        assert_eq!(
865            relfn2path_io("/sibling.txt", "sub/page", &srcdir),
866            srcdir.join("sibling.txt")
867        );
868    }
869}