Skip to main content

mdbook_repo_links/
resolver.rs

1//! Where a link target written in a chapter lands: in the book, which leaves
2//! it alone, or in the repository, which points it at GitHub.
3
4pub struct Config {
5    /// The book's `src`, where the chapters are.
6    book_src: std::path::PathBuf,
7    repo_root: std::path::PathBuf,
8    repository: String,
9    branch: String,
10}
11
12impl Config {
13    /// From mdBook's context: its `root`, `book.src`, and this preprocessor's table.
14    pub fn from_context(context: &serde_json::Value) -> Result<Config, String> {
15        let root = context
16            .get("root")
17            .and_then(serde_json::Value::as_str)
18            .ok_or("mdBook's context names no root")?;
19        let root = std::path::Path::new(root);
20        let config = context.get("config");
21        let src = config
22            .and_then(|config| config.pointer("/book/src"))
23            .and_then(serde_json::Value::as_str)
24            .unwrap_or("src");
25        let table = config.and_then(|config| config.pointer("/preprocessor/repo-links"));
26        let setting = |key: &str| {
27            table
28                .and_then(|table| table.get(key))
29                .and_then(serde_json::Value::as_str)
30        };
31        let repository = setting("repository")
32            .ok_or("book.toml sets no [preprocessor.repo-links] repository")?
33            .trim_end_matches('/')
34            .to_string();
35        let repo_root = root.join(setting("repo-root").unwrap_or(".."));
36        Ok(Config {
37            book_src: canonical(&root.join(src))?,
38            repo_root: canonical(&repo_root)?,
39            repository,
40            branch: setting("branch").unwrap_or("master").to_string(),
41        })
42    }
43}
44
45fn canonical(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
46    std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))
47}
48
49/// The two places one chapter's relative links can mean.
50pub struct Resolver<'a> {
51    config: &'a Config,
52    /// The chapter's directory in the book.
53    in_book: std::path::PathBuf,
54    /// The same directory as a path from the repository root: the original's
55    /// directory when the chapter includes a file from there, otherwise the
56    /// chapter's own place under the book.
57    in_repo: std::path::PathBuf,
58}
59
60impl<'a> Resolver<'a> {
61    pub fn for_chapter(config: &'a Config, source: &std::path::Path) -> Resolver<'a> {
62        let directory = source.parent().unwrap_or(std::path::Path::new(""));
63        let in_book = directory.to_path_buf();
64        let mirrors_the_repository = config.repo_root.join(source).is_file();
65        let in_repo = if mirrors_the_repository {
66            directory.to_path_buf()
67        } else {
68            let book = config
69                .book_src
70                .strip_prefix(&config.repo_root)
71                .unwrap_or(std::path::Path::new(""));
72            book.join(directory)
73        };
74        Resolver {
75            config,
76            in_book,
77            in_repo,
78        }
79    }
80
81    /// The GitHub address for a target that is in the repository but not in
82    /// the book; `None` for anything else, which stays as written.
83    pub fn resolve(&self, target: &str) -> Option<String> {
84        let external = target.contains("://") || target.starts_with("mailto:");
85        if target.is_empty() || external || target.starts_with(['#', '/']) {
86            return None;
87        }
88        let (path, anchor) = match target.split_once('#') {
89            Some((path, anchor)) => (path, format!("#{anchor}")),
90            None => (target, String::new()),
91        };
92        // Lexically, so a path that climbs out of the book into the
93        // repository is not taken for a page of the book.
94        let in_book = normalize(&self.in_book.join(path))
95            .is_some_and(|page| self.config.book_src.join(page).exists());
96        if path.is_empty() || in_book {
97            return None;
98        }
99        let relative = normalize(&self.in_repo.join(path))?;
100        let file = self.config.repo_root.join(&relative);
101        let kind = if file.is_dir() {
102            "tree"
103        } else if file.is_file() {
104            "blob"
105        } else {
106            return None;
107        };
108        Some(format!(
109            "{}/{kind}/{}/{relative}{anchor}",
110            self.config.repository, self.config.branch
111        ))
112    }
113}
114
115/// `path` with `.` and `..` resolved, `/`-separated; `None` when it climbs
116/// out of the repository.
117fn normalize(path: &std::path::Path) -> Option<String> {
118    let mut parts: Vec<&str> = Vec::new();
119    for component in path.components() {
120        match component {
121            std::path::Component::Normal(part) => parts.push(part.to_str()?),
122            std::path::Component::ParentDir => {
123                parts.pop()?;
124            }
125            std::path::Component::CurDir => {}
126            _ => return None,
127        }
128    }
129    Some(parts.join("/"))
130}
131
132#[cfg(test)]
133mod tests {
134    /// A repository with a book under `docs/src` mirroring `installer/DESIGN.md`.
135    struct Fixture {
136        root: std::path::PathBuf,
137    }
138
139    impl Fixture {
140        fn new(label: &str) -> Fixture {
141            let root =
142                std::env::temp_dir().join(format!("repo-links-{label}-{}", std::process::id()));
143            let _ = std::fs::remove_dir_all(&root);
144            for file in [
145                "installer/DESIGN.md",
146                "installer/src/zip.rs",
147                "LICENSE",
148                "docs/src/installer/DESIGN.md",
149                "docs/src/architecture/README.md",
150                "docs/src/architecture/diagrams/er.svg",
151            ] {
152                let path = root.join(file);
153                std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
154                std::fs::write(&path, "x").expect("write");
155            }
156            Fixture { root }
157        }
158
159        fn config(&self) -> super::Config {
160            let context = serde_json::json!({
161                "root": self.root.join("docs"),
162                "config": { "preprocessor": { "repo-links": {
163                    "repository": "https://github.com/o/r/",
164                } } }
165            });
166            super::Config::from_context(&context).expect("config")
167        }
168    }
169
170    impl Drop for Fixture {
171        fn drop(&mut self) {
172            let _ = std::fs::remove_dir_all(&self.root);
173        }
174    }
175
176    #[test]
177    fn a_mirrored_chapter_resolves_against_the_original() {
178        let fixture = Fixture::new("mirror");
179        let config = fixture.config();
180        let chapter = super::Resolver::for_chapter(&config, "installer/DESIGN.md".as_ref());
181        assert_eq!(
182            chapter.resolve("src/zip.rs#L3").as_deref(),
183            Some("https://github.com/o/r/blob/master/installer/src/zip.rs#L3")
184        );
185        assert_eq!(
186            chapter.resolve("../LICENSE").as_deref(),
187            Some("https://github.com/o/r/blob/master/LICENSE")
188        );
189        assert_eq!(
190            chapter.resolve("src/").as_deref(),
191            Some("https://github.com/o/r/tree/master/installer/src")
192        );
193        // Chapters, external links and anchors stay as written.
194        assert_eq!(chapter.resolve("DESIGN.md"), None);
195        assert_eq!(chapter.resolve("https://example.test/x"), None);
196        assert_eq!(chapter.resolve("#scene-4"), None);
197        assert_eq!(chapter.resolve("missing.rs"), None);
198        assert_eq!(chapter.resolve("../../../outside"), None);
199    }
200
201    #[test]
202    fn a_chapter_written_for_the_book_resolves_from_its_own_place() {
203        let fixture = Fixture::new("authored");
204        let config = fixture.config();
205        let chapter = super::Resolver::for_chapter(&config, "architecture/README.md".as_ref());
206        assert_eq!(chapter.resolve("diagrams/er.svg"), None);
207        assert_eq!(
208            chapter.resolve("../../../installer/src/zip.rs").as_deref(),
209            Some("https://github.com/o/r/blob/master/installer/src/zip.rs")
210        );
211    }
212}