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/// Python `str.split()` with no separator: split on runs of [`py_isspace`],
298/// dropping the empty leading/trailing/interior fields. Rust's
299/// `str::split_whitespace` is the same shape over a NARROWER set (it misses
300/// the C0 separators `\x1c`-`\x1f`), so every port of a docutils/Sphinx
301/// `.split()` must come through here.
302pub(crate) fn py_split(s: &str) -> impl Iterator<Item = &str> {
303    s.split(py_isspace).filter(|w| !w.is_empty())
304}
305
306/// `Project.path2doc` (`sphinx/project.py:114-128`) against Sphinx's
307/// *default* `source_suffix` — `{'.rst': 'restructuredtext'}`
308/// (`config.py:243`): the docname a source file under `srcdir` maps to, or
309/// `None` for anything else.
310///
311/// Deliberately narrower than this crate's discovery (which also admits
312/// `.md`/`.txt`): the consumers — `env.included` bookkeeping and the orphan
313/// check behind it — must match what Sphinx records, and Sphinx's default
314/// project never maps a `.txt` include target to a document.
315///
316/// One knowing simplification: for a `.rst` OUTSIDE `srcdir` sphinx's
317/// `relative_to` fails and the absolute path itself becomes the "docname"
318/// (`'/base/ext/part'`, probed) — a name no document can have, so the
319/// orphan check ignores it. `None` here has the same effect, and keeps
320/// `env.included` free of environment-specific absolute paths.
321pub fn path2doc(path: &Path, srcdir: &Path) -> Option<String> {
322    let rel = path.strip_prefix(srcdir).ok()?;
323    let rel = rel.to_str()?.replace('\\', "/");
324    Some(rel.strip_suffix(".rst")?.to_string())
325}
326
327#[derive(Debug)]
328pub struct ProjectStats {
329    pub source_files: usize,
330    pub total_lines: usize,
331    pub avg_file_size_kb: f64,
332    pub largest_file_kb: f64,
333    pub max_depth: usize,
334    pub cross_references: usize,
335}
336
337pub async fn analyze_project(source_dir: &Path) -> Result<ProjectStats> {
338    let mut state = AnalysisState {
339        source_files: 0,
340        total_lines: 0,
341        total_size_bytes: 0,
342        largest_file_kb: 0.0,
343        max_depth: 0,
344        cross_references: 0,
345    };
346
347    // Use synchronous approach to avoid async recursion issues
348    analyze_directory_sync(source_dir, source_dir, 0, &mut state)?;
349
350    let avg_file_size_kb = if state.source_files > 0 {
351        (state.total_size_bytes as f64) / (state.source_files as f64) / 1024.0
352    } else {
353        0.0
354    };
355
356    Ok(ProjectStats {
357        source_files: state.source_files,
358        total_lines: state.total_lines,
359        avg_file_size_kb,
360        largest_file_kb: state.largest_file_kb,
361        max_depth: state.max_depth,
362        cross_references: state.cross_references,
363    })
364}
365
366/// Analysis state for directory traversal
367struct AnalysisState {
368    source_files: usize,
369    total_lines: usize,
370    total_size_bytes: u64,
371    largest_file_kb: f64,
372    max_depth: usize,
373    cross_references: usize,
374}
375
376fn analyze_directory_sync(
377    dir: &Path,
378    _root_dir: &Path,
379    current_depth: usize,
380    state: &mut AnalysisState,
381) -> Result<()> {
382    state.max_depth = state.max_depth.max(current_depth);
383
384    for entry in std::fs::read_dir(dir)? {
385        let entry = entry?;
386        let path = entry.path();
387
388        if path.is_dir() {
389            // Skip hidden directories
390            if let Some(name) = path.file_name() {
391                if name.to_string_lossy().starts_with('.') {
392                    continue;
393                }
394            }
395
396            analyze_directory_sync(&path, _root_dir, current_depth + 1, state)?;
397        } else if is_source_file(&path) {
398            state.source_files += 1;
399
400            let metadata = std::fs::metadata(&path)?;
401            let file_size_bytes = metadata.len();
402            let file_size_kb = file_size_bytes as f64 / 1024.0;
403
404            state.total_size_bytes += file_size_bytes;
405            state.largest_file_kb = state.largest_file_kb.max(file_size_kb);
406
407            // Count lines and cross-references
408            if let Ok(content) = std::fs::read_to_string(&path) {
409                state.total_lines += content.lines().count();
410                state.cross_references += count_cross_references(&content);
411            }
412        }
413    }
414
415    Ok(())
416}
417
418pub fn is_source_file(path: &Path) -> bool {
419    if let Some(ext) = path.extension() {
420        matches!(ext.to_string_lossy().as_ref(), "rst" | "md" | "txt")
421    } else {
422        false
423    }
424}
425
426pub fn count_cross_references(content: &str) -> usize {
427    let patterns = [
428        r":doc:`",
429        r":ref:`",
430        r":func:`",
431        r":class:`",
432        r":meth:`",
433        r":attr:`",
434        r":mod:`",
435        r":py:",
436        r".. _",
437        r"`~",
438    ];
439
440    let mut count = 0;
441    for pattern in &patterns {
442        count += content.matches(pattern).count();
443    }
444    count
445}
446
447pub fn get_file_mtime(path: &Path) -> Result<DateTime<Utc>> {
448    let metadata = std::fs::metadata(path)?;
449    let mtime = metadata.modified()?;
450    Ok(DateTime::from(mtime))
451}
452
453pub async fn calculate_directory_size(dir: &Path) -> Result<u64> {
454    // Use synchronous approach
455    calculate_directory_size_sync(dir)
456}
457
458fn calculate_directory_size_sync(dir: &Path) -> Result<u64> {
459    let mut total_size = 0;
460
461    for entry in std::fs::read_dir(dir)? {
462        let entry = entry?;
463        let path = entry.path();
464
465        if path.is_dir() {
466            total_size += calculate_directory_size_sync(&path)?;
467        } else {
468            let metadata = std::fs::metadata(&path)?;
469            total_size += metadata.len();
470        }
471    }
472
473    Ok(total_size)
474}
475
476pub async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
477    // Use synchronous approach
478    copy_dir_recursive_sync(src, dst)
479}
480
481fn copy_dir_recursive_sync(src: &Path, dst: &Path) -> Result<()> {
482    std::fs::create_dir_all(dst)?;
483
484    for entry in std::fs::read_dir(src)? {
485        let entry = entry?;
486        let src_path = entry.path();
487        let dst_path = dst.join(entry.file_name());
488
489        if src_path.is_dir() {
490            copy_dir_recursive_sync(&src_path, &dst_path)?;
491        } else {
492            std::fs::copy(&src_path, &dst_path)?;
493        }
494    }
495
496    Ok(())
497}
498
499#[allow(dead_code)]
500pub fn format_duration(duration: std::time::Duration) -> String {
501    let secs = duration.as_secs();
502    let millis = duration.subsec_millis();
503
504    if secs > 0 {
505        format!("{}.{:03}s", secs, millis)
506    } else {
507        format!("{}ms", millis)
508    }
509}
510
511#[allow(dead_code)]
512pub fn format_bytes(bytes: u64) -> String {
513    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
514
515    if bytes == 0 {
516        return "0 B".to_string();
517    }
518
519    let mut size = bytes as f64;
520    let mut unit_index = 0;
521
522    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
523        size /= 1024.0;
524        unit_index += 1;
525    }
526
527    format!("{:.1} {}", size, UNITS[unit_index])
528}
529
530/// Format a date according to the specified format string and language
531#[allow(dead_code)]
532pub fn format_date(fmt: &str, _language: &Option<String>) -> String {
533    let now = chrono::Utc::now();
534
535    match fmt {
536        "%b %d, %Y" => now.format("%b %d, %Y").to_string(),
537        "%B %d, %Y" => now.format("%B %d, %Y").to_string(),
538        "%Y-%m-%d" => now.format("%Y-%m-%d").to_string(),
539        "%Y-%m-%d %H:%M:%S" => now.format("%Y-%m-%d %H:%M:%S").to_string(),
540        _ => {
541            // For custom formats, try to parse and format
542            match chrono::DateTime::parse_from_str(&now.to_rfc3339(), "%+") {
543                Ok(dt) => dt.format(fmt).to_string(),
544                Err(_) => now.format("%Y-%m-%d").to_string(),
545            }
546        }
547    }
548}
549
550/// Ensure a directory exists, creating it if necessary
551#[allow(dead_code)]
552pub async fn ensure_dir(path: &Path) -> Result<()> {
553    use tokio::fs;
554
555    if !path.exists() {
556        fs::create_dir_all(path).await?;
557    }
558    Ok(())
559}
560
561/// Calculate relative URI from one path to another
562#[allow(dead_code)]
563pub fn relative_uri(from: &str, to: &str, suffix: &str) -> String {
564    use std::path::Path;
565
566    let from_path = Path::new(from);
567    let to_path = Path::new(to);
568
569    // Get the relative path
570    if let Some(rel_path) =
571        pathdiff::diff_paths(to_path, from_path.parent().unwrap_or(Path::new("")))
572    {
573        let mut result = rel_path.to_string_lossy().to_string();
574        if !suffix.is_empty() && !result.ends_with(suffix) {
575            result.push_str(suffix);
576        }
577        result.replace('\\', "/") // Ensure forward slashes
578    } else {
579        format!("{}{}", to, suffix)
580    }
581}
582
583/// Copy all files and directories from source to destination
584#[allow(dead_code)]
585pub async fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
586    use tokio::fs;
587
588    ensure_dir(dst).await?;
589
590    let mut entries = fs::read_dir(src).await?;
591
592    while let Some(entry) = entries.next_entry().await? {
593        let entry_path = entry.path();
594        let file_name = entry.file_name();
595        let dest_path = dst.join(file_name);
596
597        if entry_path.is_dir() {
598            Box::pin(copy_dir_all(&entry_path, &dest_path)).await?;
599        } else {
600            if let Some(parent) = dest_path.parent() {
601                ensure_dir(parent).await?;
602            }
603            fs::copy(&entry_path, &dest_path).await?;
604        }
605    }
606
607    Ok(())
608}
609
610/// Python `str.splitlines()`: the full boundary set (`\n`, `\r`, `\r\n`,
611/// `\v`, `\f`, `\x1c`-`\x1e`, `\u{85}`, `\u{2028}`, `\u{2029}`), no
612/// trailing empty line for a terminal boundary.
613///
614/// Shared home: the block parser's line handling and
615/// [`crate::doctree::pformat`]'s `Text.pformat` port both need it.
616pub(crate) fn py_splitlines(text: &str) -> Vec<&str> {
617    let is_boundary = |c: char| {
618        matches!(
619            c,
620            '\n' | '\r'
621                | '\x0b'
622                | '\x0c'
623                | '\x1c'
624                | '\x1d'
625                | '\x1e'
626                | '\u{85}'
627                | '\u{2028}'
628                | '\u{2029}'
629        )
630    };
631    let mut out = Vec::new();
632    let mut start = 0usize;
633    let mut chars = text.char_indices().peekable();
634    while let Some((i, c)) = chars.next() {
635        if is_boundary(c) {
636            out.push(&text[start..i]);
637            if c == '\r' {
638                if let Some(&(_, '\n')) = chars.peek() {
639                    chars.next();
640                }
641            }
642            start = chars.peek().map(|&(j, _)| j).unwrap_or(text.len());
643        }
644    }
645    if start < text.len() {
646        out.push(&text[start..]);
647    }
648    out
649}
650
651#[cfg(test)]
652mod path_tests {
653    use super::*;
654
655    #[test]
656    fn relfn2path_rel_resolves_docname_relative_and_rooted_forms() {
657        assert_eq!(
658            relfn2path_rel("part.rst", "chapters/intro"),
659            "chapters/part.rst"
660        );
661        assert_eq!(
662            relfn2path_rel("/sub/abs.rst", "chapters/intro"),
663            "sub/abs.rst"
664        );
665        assert_eq!(
666            relfn2path_rel("../img/./pic.png", "chapters/intro"),
667            "img/pic.png"
668        );
669        assert_eq!(relfn2path_rel("x.rst", "index"), "x.rst");
670        // A leading `..` walks out of the tree and stays.
671        assert_eq!(relfn2path_rel("../outside.rst", "index"), "../outside.rst");
672    }
673
674    #[test]
675    fn relfn2path_joins_the_rel_half_onto_srcdir() {
676        assert_eq!(
677            relfn2path("part.rst", "chapters/intro", Path::new("/src")),
678            PathBuf::from("/src/chapters/part.rst")
679        );
680    }
681
682    /// Sphinx's default `source_suffix` is `.rst` alone; this crate's wider
683    /// discovery (`.md`/`.txt`) deliberately does not leak into the
684    /// `env.included` bookkeeping this helper feeds.
685    #[test]
686    fn path2doc_maps_rst_under_srcdir_and_nothing_else() {
687        let srcdir = Path::new("/src");
688        assert_eq!(
689            path2doc(Path::new("/src/part.rst"), srcdir),
690            Some("part".into())
691        );
692        assert_eq!(
693            path2doc(Path::new("/src/sub/abs_part.rst"), srcdir),
694            Some("sub/abs_part".into())
695        );
696        assert_eq!(path2doc(Path::new("/src/data.txt"), srcdir), None);
697        assert_eq!(path2doc(Path::new("/src/notes.md"), srcdir), None);
698        assert_eq!(path2doc(Path::new("/elsewhere/part.rst"), srcdir), None);
699    }
700
701    /// `_relative_path(abs_fn, srcdir)`: inside the tree it is the plain
702    /// relative spelling; outside it walks up with `..`, which is what
703    /// makes `srcdir / rel` name the file sphinx actually read.
704    ///
705    // oracle (sphinx 9.1.0, probe_symlink.py s1/s5): an include resolving
706    // to BASE/ext/part.rst from srcdir BASE/src records
707    // env.dependencies == {'index': {BASE/'src/../ext/part.rst'}}.
708    #[test]
709    fn relative_path_walk_up_matches_sphinxs_relative_to() {
710        let root = Path::new("/base/src");
711        assert_eq!(
712            relative_path_walk_up(Path::new("/base/src/a/c.rst"), root),
713            "a/c.rst"
714        );
715        assert_eq!(
716            relative_path_walk_up(Path::new("/base/ext/part.rst"), root),
717            "../ext/part.rst"
718        );
719        assert_eq!(
720            relative_path_walk_up(Path::new("/other/x.txt"), root),
721            "../../other/x.txt"
722        );
723        assert_eq!(relative_path_walk_up(Path::new("/base/src"), root), "");
724    }
725
726    /// Python's `isspace` set is Unicode White_Space plus `\x1c`-`\x1f`.
727    #[test]
728    fn py_isspace_is_unicode_whitespace_plus_the_c0_separators() {
729        for c in [
730            ' ', '\t', '\n', '\u{a0}', '\u{3000}', '\x1c', '\x1d', '\x1e', '\x1f',
731        ] {
732            assert!(py_isspace(c), "{c:?}");
733        }
734        for c in ['a', '\x00', '\x1b', '\u{200b}'] {
735            assert!(!py_isspace(c), "{c:?}");
736        }
737        assert!(!'\x1f'.is_whitespace(), "the case Rust's predicate misses");
738    }
739
740    /// CPython 3.12 `repr()`: quote choice, the four named escapes, and
741    /// `\xNN`/`\uNNNN` for every non-`str.isprintable()` character —
742    /// probed (`repr('term\xa0')` → `'term\xa0'`, `repr('a\u3000b')` →
743    /// `'a\u3000b'`, `repr('a\x85b')` → `'a\x85b'`; panel fix round F).
744    #[test]
745    fn py_repr_str_quotes_and_escapes_like_cpython() {
746        assert_eq!(py_repr_str("a"), "'a'");
747        assert_eq!(py_repr_str("it's"), "\"it's\"");
748        assert_eq!(py_repr_str("say \"hi\""), "'say \"hi\"'");
749        assert_eq!(py_repr_str("both ' and \""), "'both \\' and \"'");
750        assert_eq!(py_repr_str("a\\b"), "'a\\\\b'");
751        assert_eq!(py_repr_str("a\nb"), "'a\\nb'");
752        assert_eq!(py_repr_str("a\tb\rc"), "'a\\tb\\rc'");
753        assert_eq!(py_repr_str("term\u{a0}"), "'term\\xa0'");
754        assert_eq!(py_repr_str("foo\u{a0}bar"), "'foo\\xa0bar'");
755        assert_eq!(py_repr_str("a\u{3000}b"), "'a\\u3000b'");
756        assert_eq!(py_repr_str("a\u{85}b"), "'a\\x85b'");
757        assert_eq!(py_repr_str("a\x1fb\x7f"), "'a\\x1fb\\x7f'");
758        assert_eq!(py_repr_str("a\u{2028}b"), "'a\\u2028b'");
759        assert_eq!(py_repr_str("é ü"), "'é ü'", "printable non-ASCII stays raw");
760    }
761
762    /// [`simplify_verbatim`] over Windows-shaped literals, which is the
763    /// only way to exercise the rule off Windows (`canonicalize` there
764    /// hands back exactly these shapes). The plain spelling is what
765    /// `pathlib.Path.resolve()` returns, and the only one Windows
766    /// normalizes `/` and `..` inside.
767    #[test]
768    fn the_verbatim_prefix_is_stripped_back_to_the_python_spelling() {
769        let simplify = |text: &str| {
770            simplify_verbatim(PathBuf::from(text))
771                .to_string_lossy()
772                .into_owned()
773        };
774        assert_eq!(simplify(r"\\?\C:\Users\me\docs"), r"C:\Users\me\docs");
775        assert_eq!(simplify(r"\\?\c:\x"), r"c:\x");
776        assert_eq!(simplify(r"\\?\UNC\server\share\doc"), r"\\server\share\doc");
777        // Not a drive path: no plain spelling exists, so it keeps the
778        // prefix rather than becoming unopenable.
779        assert_eq!(simplify(r"\\?\Volume{9f8a}\x"), r"\\?\Volume{9f8a}\x");
780        // Past MAX_PATH the prefix is what makes the path openable.
781        let long = format!(r"\\?\C:\{}", "a".repeat(300));
782        assert_eq!(simplify(&long), long);
783        // Everything else — every POSIX path included — is untouched.
784        assert_eq!(simplify("/tmp/x/y"), "/tmp/x/y");
785        assert_eq!(simplify(r"C:\already\plain"), r"C:\already\plain");
786        assert_eq!(simplify(r"\\server\share"), r"\\server\share");
787    }
788
789    /// Sphinx `.resolve()`s the joined path, so `..` walks up from a
790    /// symlink's TARGET, not from the link's own parent. The lexical
791    /// collapse [`relfn2path`] keeps for §Scope-8 display spellings gets
792    /// this wrong, which is why the read goes through
793    /// [`relfn2path_io`].
794    ///
795    // oracle: sphinx/environment/__init__.py:475
796    //   `abs_fn = self.srcdir.joinpath(doc_dir, file_name).resolve()`
797    //   (probed: with BASE/src/link -> BASE/ext, a literalinclude of
798    //   `link/../secret.txt` reads BASE/ext/../secret.txt = BASE/secret.txt,
799    //   not BASE/src/secret.txt).
800    #[test]
801    fn relfn2path_io_walks_up_from_the_symlink_target() {
802        let base = tempfile::tempdir().unwrap();
803        let base = canonicalize_simplified(base.path()).unwrap();
804        let srcdir = base.join("src");
805        std::fs::create_dir_all(srcdir.join("real")).unwrap();
806        std::fs::create_dir_all(base.join("ext/inner")).unwrap();
807        std::fs::write(base.join("ext/sibling.txt"), "OUTSIDE\n").unwrap();
808        std::fs::write(srcdir.join("sibling.txt"), "INSIDE\n").unwrap();
809        #[cfg(unix)]
810        std::os::unix::fs::symlink(base.join("ext/inner"), srcdir.join("link")).unwrap();
811
812        // Lexically, `link/../sibling.txt` is `sibling.txt` under srcdir.
813        assert_eq!(
814            relfn2path("link/../sibling.txt", "index", &srcdir),
815            srcdir.join("sibling.txt")
816        );
817        // Resolved, `link` is `<base>/ext/inner`, so `..` lands in
818        // `<base>/ext` — a different file entirely.
819        #[cfg(unix)]
820        assert_eq!(
821            relfn2path_io("link/../sibling.txt", "index", &srcdir),
822            base.join("ext/sibling.txt")
823        );
824
825        // A path with no symlink in it is unchanged by the resolve, and a
826        // target that does not exist yet still normalizes.
827        assert_eq!(
828            relfn2path_io("real/../sibling.txt", "index", &srcdir),
829            srcdir.join("sibling.txt")
830        );
831        assert_eq!(
832            relfn2path_io("real/../nothere.txt", "index", &srcdir),
833            srcdir.join("nothere.txt")
834        );
835        assert_eq!(
836            relfn2path_io("/sibling.txt", "sub/page", &srcdir),
837            srcdir.join("sibling.txt")
838        );
839    }
840}