1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6pub fn canonicalize_simplified(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
14 std::fs::canonicalize(path).map(simplify_verbatim)
15}
16
17pub 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
49fn simplify_verbatim_str(text: &str) -> Cow<'_, str> {
52 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 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
78pub 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
98pub 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
122fn 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
136pub(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
164pub 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
178pub 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 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
200pub(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
237pub(crate) fn py_isspace(c: char) -> bool {
244 c.is_whitespace() || matches!(c, '\x1c'..='\x1f')
245}
246
247pub(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
297pub 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
315pub(crate) fn py_split(s: &str) -> impl Iterator<Item = &str> {
321 s.split(py_isspace).filter(|w| !w.is_empty())
322}
323
324pub 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 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
384struct 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 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 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 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 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#[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 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#[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#[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 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('\\', "/") } else {
597 format!("{}{}", to, suffix)
598 }
599}
600
601#[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
628pub(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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(simplify(r"\\?\Volume{9f8a}\x"), r"\\?\Volume{9f8a}\x");
809 let long = format!(r"\\?\C:\{}", "a".repeat(300));
811 assert_eq!(simplify(&long), long);
812 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 #[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 assert_eq!(
843 relfn2path("link/../sibling.txt", "index", &srcdir),
844 srcdir.join("sibling.txt")
845 );
846 #[cfg(unix)]
849 assert_eq!(
850 relfn2path_io("link/../sibling.txt", "index", &srcdir),
851 base.join("ext/sibling.txt")
852 );
853
854 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}