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(crate) fn py_split(s: &str) -> impl Iterator<Item = &str> {
303 s.split(py_isspace).filter(|w| !w.is_empty())
304}
305
306pub 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 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
366struct 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 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 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 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 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#[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 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#[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#[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 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('\\', "/") } else {
579 format!("{}{}", to, suffix)
580 }
581}
582
583#[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
610pub(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 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 #[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 #[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 #[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 #[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 #[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 assert_eq!(simplify(r"\\?\Volume{9f8a}\x"), r"\\?\Volume{9f8a}\x");
780 let long = format!(r"\\?\C:\{}", "a".repeat(300));
782 assert_eq!(simplify(&long), long);
783 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 #[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 assert_eq!(
814 relfn2path("link/../sibling.txt", "index", &srcdir),
815 srcdir.join("sibling.txt")
816 );
817 #[cfg(unix)]
820 assert_eq!(
821 relfn2path_io("link/../sibling.txt", "index", &srcdir),
822 base.join("ext/sibling.txt")
823 );
824
825 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}