toolkit for mdBook [mirror of my GitHub repo] docs.tonywu.dev/mdbookkit/
permalinks rust-analyzer mdbook
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(permalinks): check if path is ignored

Tony Wu 70aa7000 d4365fd8

+173 -154
+14 -15
crates/mdbook-permalinks/src/diagnostic.rs
··· 39 39 let sorted = sorted 40 40 .into_iter() 41 41 .flat_map(|(base, issues)| { 42 - let text = content.get_text(base).unwrap(); 42 + let text = content.get_text(base).expect("url should exist"); 43 43 issues 44 44 .into_values() 45 45 .map(|issues| Diagnostics::new(text, base, issues)) ··· 80 80 let status = &self.link.status; 81 81 let label = match status { 82 82 LinkStatus::Ignored => None, 83 - LinkStatus::Published => Some(path.into()), 83 + LinkStatus::Unchanged => Some(path.into()), 84 84 LinkStatus::Permalink => Some(path.into()), 85 85 LinkStatus::Rewritten => Some(format!("path: {path}\nlink: {:?}", &**link)), 86 - LinkStatus::PathNotCheckedIn => Some(format!("resolves to {path}")), 87 - LinkStatus::NoSuchPath(candidates) => candidates 86 + LinkStatus::Unreachable(errors) => errors 88 87 .iter() 89 - .filter_map(|url| self.shorten_url(url)) 90 - .fold(String::new(), |mut err, line| { 91 - err.push_str(&line); 92 - err.push('\n'); 93 - err 88 + .filter_map(|(url, err)| self.shorten_url(url).map(|url| (url, err))) 89 + .fold(String::new(), |mut msg, (url, err)| { 90 + msg.push_str(&url); 91 + msg.push(' '); 92 + msg.push_str(&err.to_string()); 93 + msg.push('\n'); 94 + msg 94 95 }) 95 96 .trim() 96 97 .to_owned() ··· 144 145 fn level(&self) -> log::Level { 145 146 match self { 146 147 Self::Ignored => log::Level::Debug, 147 - Self::Published => log::Level::Debug, 148 + Self::Unchanged => log::Level::Debug, 148 149 Self::Rewritten => log::Level::Info, 149 150 Self::Permalink => log::Level::Info, 150 - Self::PathNotCheckedIn => log::Level::Warn, 151 - Self::NoSuchPath(..) => log::Level::Warn, 151 + Self::Unreachable(..) => log::Level::Warn, 152 152 Self::Error(..) => log::Level::Warn, 153 153 } 154 154 } ··· 178 178 fn order(&self) -> usize { 179 179 match self { 180 180 Self::Error(..) => 103, 181 - Self::NoSuchPath(..) => 101, 182 - Self::PathNotCheckedIn => 100, 181 + Self::Unreachable(..) => 101, 183 182 Self::Permalink => 3, 184 183 Self::Rewritten => 2, 185 - Self::Published => 1, 184 + Self::Unchanged => 1, 186 185 Self::Ignored => 0, 187 186 } 188 187 }
+5 -7
crates/mdbook-permalinks/src/link.rs
··· 3 3 use mdbook_markdown::pulldown_cmark::{CowStr, Event, LinkType, Tag, TagEnd}; 4 4 use url::Url; 5 5 6 + use crate::vcs::PathError; 7 + 6 8 #[derive(Debug, Default, Clone, thiserror::Error)] 7 9 pub enum LinkStatus { 8 10 #[default] 9 11 #[error("links ignored")] 10 12 Ignored, 11 13 12 - // "published" as in published with the book 13 14 #[error("linking to book page or file")] 14 - Published, 15 - 15 + Unchanged, 16 16 #[error("linking to book page or file, rewritten as paths")] 17 17 Rewritten, 18 18 #[error("links converted to permalinks")] 19 19 Permalink, 20 20 21 - #[error("paths outside of repository")] 22 - PathNotCheckedIn, 23 - #[error("paths inaccessible")] 24 - NoSuchPath(Vec<Url>), 21 + #[error("links inaccessible")] 22 + Unreachable(Vec<(Url, PathError)>), 25 23 26 24 #[error("error encountered: {0}")] 27 25 Error(String),
+45 -75
crates/mdbook-permalinks/src/main.rs
··· 2 2 3 3 use anyhow::{Context, Result, anyhow}; 4 4 use console::colors_enabled_stderr; 5 + use git2::Repository; 5 6 use log::LevelFilter; 6 7 use mdbook_markdown::pulldown_cmark; 7 8 use mdbook_preprocessor::{Preprocessor, PreprocessorContext, book::Book}; ··· 62 63 struct VersionControl { 63 64 root: Url, 64 65 link: Permalink, 66 + repo: Repository, 65 67 } 66 68 67 69 impl Preprocessor for Environment { ··· 88 90 let mut result = book 89 91 .iter_chapters() 90 92 .filter_map(|(path, _)| { 91 - let url = self.book_src.join(&path.to_string_lossy()).unwrap(); 93 + let url = self.book_src.join(&path.to_string_lossy()).ok()?; 92 94 content 93 95 .emit(&url) 94 96 .tap_err(log_warning!()) ··· 206 208 207 209 impl Resolver<'_, '_> { 208 210 fn resolve(self) { 209 - if let Some(book) = &self.env.config.book_url { 210 - if let Some(path) = book.0.make_relative(&self.file_url) 211 - && !path.starts_with("../") 212 - { 213 - self.resolve_book(path) 214 - } else { 215 - self.resolve_file() 216 - } 211 + if let Some(book) = &self.env.config.book_url 212 + && let Some(path) = book.0.make_relative(&self.file_url) 213 + && !path.starts_with("../") 214 + { 215 + self.resolve_book(path) 217 216 } else { 218 217 self.resolve_file() 219 218 } ··· 234 233 { 235 234 let (_, suffix) = UrlSuffix::take(file_url); 236 235 (url, hint, suffix, true) 237 - } else { 236 + } else if file_url.scheme() == "file" { 238 237 let (url, suffix) = UrlSuffix::take(file_url); 239 238 (url, link.hint, suffix, false) 240 - }; 241 - 242 - let Ok(path) = file_url.to_file_path() else { 243 - link.status = LinkStatus::Ignored; 239 + } else { 244 240 return; 245 241 }; 246 242 247 - let Ok(relative_to_repo) = env 248 - .vcs 249 - .root 250 - .make_relative(&file_url) 251 - .context("url is from a different origin") 252 - .tap_err(log_debug!()) 253 - else { 254 - return; 243 + let relative_to_repo = match self.env.vcs.try_file(&file_url) { 244 + Ok(path) => path, 245 + Err(err) => { 246 + link.status = LinkStatus::Unreachable(vec![(file_url, err)]); 247 + return; 248 + } 255 249 }; 256 250 257 - if relative_to_repo.starts_with("../") { 258 - link.status = LinkStatus::PathNotCheckedIn; 259 - return; 260 - } 261 - 262 - let exists = path 263 - .try_exists() 264 - .context("could not access path") 265 - .tap_err(log_debug!()); 266 - 267 - if !matches!(exists, Ok(true)) { 268 - link.status = LinkStatus::NoSuchPath(vec![file_url]); 269 - return; 270 - } 271 - 272 - let Ok(relative_to_book) = env 251 + let relative_to_book = env 273 252 .book_src 274 253 .make_relative(&file_url) 275 - .context("url is from a different origin") 276 - .tap_err(log_debug!()) 277 - else { 278 - return; 279 - }; 254 + .expect("should be a file"); 280 255 281 - let always_link = is_vcs 256 + let should_link = is_vcs 282 257 || relative_to_book.starts_with("../") 283 258 || env 284 259 .config ··· 286 261 .iter() 287 262 .any(|suffix| file_url.path().ends_with(suffix)); 288 263 289 - if !always_link { 264 + if !should_link { 290 265 if link.link.starts_with('/') { 291 266 // mdbook doesn't support absolute paths like VS Code does 292 267 link.link = page_url 293 268 .make_relative(&suffix.restored(file_url)) 294 - .unwrap() 269 + .expect("both should be file: urls") 295 270 .into(); 296 271 link.status = LinkStatus::Rewritten; 297 272 } else { 298 - link.status = LinkStatus::Published; 273 + link.status = LinkStatus::Unchanged; 299 274 } 300 275 return; 301 276 } ··· 331 306 .unwrap_or(path) 332 307 }; 333 308 334 - if path.starts_with("../") { 335 - link.status = LinkStatus::Ignored; 336 - return; 337 - } 338 - 339 309 // one does not simply avoid trailing slash issues... 340 310 // https://github.com/slorber/trailing-slash-guide 341 311 let try_files = if path.is_empty() || path.ends_with('/') { ··· 361 331 let mut not_found = vec![]; 362 332 363 333 for file in try_files { 364 - let Ok(file) = self.env.book_src.join(file).tap_err(log_debug!()) else { 365 - continue; 366 - }; 367 - 368 - let Ok(path) = file.to_file_path() else { 334 + let Ok(file) = (self.env.book_src.join(file)) 335 + .with_context(|| format!("invalid URL path {file:?}")) 336 + .tap_err(log_debug!()) 337 + else { 369 338 continue; 370 339 }; 371 340 372 - let exists = path 373 - .try_exists() 374 - .context("could not access path") 375 - .tap_err(log_debug!()); 376 - 377 - if matches!(exists, Ok(true)) { 378 - let file_url = { 379 - let mut file = file; 380 - file.set_query(file_url.query()); 381 - file.set_fragment(file_url.fragment()); 382 - file 383 - }; 341 + match self.env.vcs.try_file(&file) { 342 + Ok(_) => { 343 + let file_url = { 344 + let mut file = file; 345 + file.set_query(file_url.query()); 346 + file.set_fragment(file_url.fragment()); 347 + file 348 + }; 384 349 385 - link.link = page_url.make_relative(&file_url).unwrap().into(); 386 - link.status = LinkStatus::Rewritten; 350 + link.link = page_url 351 + .make_relative(&file_url) 352 + .expect("both should be file: urls") 353 + .into(); 354 + link.status = LinkStatus::Rewritten; 387 355 388 - return; 356 + return; 357 + } 358 + Err(err) => { 359 + not_found.push((file, err)); 360 + } 389 361 } 390 - 391 - not_found.push(file); 392 362 } 393 363 394 - link.status = LinkStatus::NoSuchPath(not_found); 364 + link.status = LinkStatus::Unreachable(not_found); 395 365 } 396 366 } 397 367
+1 -1
crates/mdbook-permalinks/src/page.rs
··· 93 93 _ => unreachable!(), 94 94 }; 95 95 let link = RelativeLink { 96 - status: LinkStatus::PathNotCheckedIn, 96 + status: LinkStatus::Ignored, 97 97 span, 98 98 link, 99 99 hint: usage,
+25 -14
crates/mdbook-permalinks/src/tests.rs
··· 1 - use std::sync::LazyLock; 1 + use std::sync::{Arc, Mutex}; 2 2 3 3 use anyhow::Result; 4 + use git2::Repository; 4 5 use log::LevelFilter; 5 6 use rstest::*; 6 7 use url::Url; ··· 14 15 use crate::{Config, Environment, VersionControl, link::LinkStatus, page::Pages, vcs::Permalink}; 15 16 16 17 struct Fixture { 17 - env: Environment, 18 18 pages: Pages<'static>, 19 + env: Arc<Mutex<Environment>>, 19 20 } 20 21 21 - static FIXTURE: LazyLock<Fixture> = LazyLock::new(|| { 22 + #[fixture] 23 + #[once] 24 + fn fixture() -> Fixture { 22 25 (|| -> Result<_> { 23 26 setup_logging(env!("CARGO_PKG_NAME")); 24 27 ··· 31 34 .unwrap(), 32 35 reference: "v0.0".into(), 33 36 }, 37 + repo: Repository::open_from_env().unwrap(), 34 38 }, 35 39 book_src: CARGO_WORKSPACE_DIR 36 40 .join("crates/")? ··· 51 55 52 56 env.resolve(&mut pages); 53 57 58 + let env = Arc::new(Mutex::new(env)); 59 + 54 60 Ok(Fixture { env, pages }) 55 61 })() 56 62 .unwrap() 57 - }); 63 + } 58 64 59 - fn assert_output(doc: TestDocument) -> Result<()> { 60 - let output = FIXTURE.pages.emit(&doc.url())?; 65 + fn assert_output(doc: TestDocument, fixture: &Fixture) -> Result<()> { 66 + let output = fixture.pages.emit(&doc.url())?; 61 67 portable_snapshots!().test(|| insta::assert_snapshot!(doc.name(), output))?; 62 68 Ok(()) 63 69 } ··· 70 76 71 77 #[rstest] 72 78 $(#[case(test_document!($path))])* 73 - fn test_output(#[case] doc: TestDocument) -> Result<()> { 74 - assert_output(doc) 79 + fn test_output(#[case] doc: TestDocument, fixture: &Fixture) -> Result<()> { 80 + assert_output(doc, fixture) 75 81 } 76 82 }; 77 83 } ··· 86 92 87 93 #[rstest] 88 94 #[case("_stderr.ignored", matcher!(LinkStatus::Ignored))] 89 - #[case("_stderr.published", matcher!(LinkStatus::Published))] 95 + #[case("_stderr.published", matcher!(LinkStatus::Unchanged))] 90 96 #[case("_stderr.rewritten", matcher!(LinkStatus::Rewritten))] 91 97 #[case("_stderr.permalink", matcher!(LinkStatus::Permalink))] 92 - #[case("_stderr.not-checked-in", matcher!(LinkStatus::PathNotCheckedIn))] 93 - #[case("_stderr.no-such-path", matcher!(LinkStatus::NoSuchPath(..)))] 98 + #[case("_stderr.unreachable", matcher!(LinkStatus::Unreachable(..)))] 94 99 #[case("_stderr.link-error", matcher!(LinkStatus::Error(..)))] 95 - fn test_stderr(#[case] name: &str, #[case] matcher: impl Fn(&LinkStatus) -> bool) -> Result<()> { 96 - let Fixture { env, pages } = &*FIXTURE; 100 + fn test_stderr( 101 + #[case] name: &str, 102 + #[case] test: impl Fn(&LinkStatus) -> bool, 103 + fixture: &Fixture, 104 + ) -> Result<()> { 105 + let Fixture { env, pages } = fixture; 106 + let env = env.lock().unwrap(); 97 107 let report = env 98 - .report_issues(pages, matcher) 108 + .report_issues(pages, test) 99 109 .level(LevelFilter::Debug) 100 110 .names(|url| env.rel_path(url)) 101 111 .colored(false) 102 112 .logging(false) 103 113 .build() 104 114 .to_report(); 115 + drop(env); 105 116 portable_snapshots!().test(|| insta::assert_snapshot!(name, report))?; 106 117 Ok(()) 107 118 }
+6
crates/mdbook-permalinks/src/tests/paths.md
··· 33 33 [](//LICENSE-MIT.md) 34 34 35 35 ![](shinjuku.jpg) 36 + 37 + # gitignored 38 + 39 + [](/target/.rustc_info.json) 40 + 41 + [](/target/debug)
+23 -12
crates/mdbook-permalinks/src/tests/snaps/_stderr.no-such-path.snap crates/mdbook-permalinks/src/tests/snaps/_stderr.unreachable.snap
··· 1 1 --- 2 2 source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 105 4 3 expression: report 5 4 --- 6 - warning: paths inaccessible 5 + warning: links inaccessible 7 6 ╭─[crates/mdbook-permalinks/src/tests/paths.md:31:1] 8 7 9 8 │ [](../../Cargo.lock) 10 9 · ──────────┬───────── 11 - · ╰── crates/mdbook-permalinks/Cargo.lock 10 + · ╰── crates/mdbook-permalinks/Cargo.lock does not exist 12 11 13 12 │ [](//LICENSE-MIT.md) 13 + · ──────────┬───────── 14 + · ╰── /LICENSE-MIT.md is not in repo 14 15 15 16 │ ![](shinjuku.jpg) 16 17 · ────────┬──────── 17 - · ╰── crates/mdbook-permalinks/src/tests/shinjuku.jpg 18 + · ╰── crates/mdbook-permalinks/src/tests/shinjuku.jpg does not exist 19 + 20 + │ # gitignored 21 + 22 + │ [](/target/.rustc_info.json) 23 + · ──────────────┬───────────── 24 + · ╰── target/.rustc_info.json is ignored by git 25 + 26 + │ [](/target/debug) 27 + · ────────┬──────── 28 + · ╰── target/debug is ignored by git 18 29 ╰──── 19 30 20 - warning: paths inaccessible 31 + warning: links inaccessible 21 32 ╭─[crates/mdbook-permalinks/src/tests/urls.md:23:1] 22 33 23 34 │ [](https://example.org/book/tests/urls/) 24 35 · ────────────────────┬─────────────────── 25 - · ╰─┤ crates/mdbook-permalinks/src/tests/urls/index.md 26 - · │ crates/mdbook-permalinks/src/tests/urls/README.md 36 + · ╰─┤ crates/mdbook-permalinks/src/tests/urls/index.md does not exist 37 + · │ crates/mdbook-permalinks/src/tests/urls/README.md does not exist 27 38 28 39 │ [](https://example.org/book/tests/url) 29 40 · ───────────────────┬────────────────── 30 - · ╰─┤ crates/mdbook-permalinks/src/tests/url.md 31 - · │ crates/mdbook-permalinks/src/tests/url/index.md 32 - · │ crates/mdbook-permalinks/src/tests/url/README.md 33 - · │ crates/mdbook-permalinks/src/tests/url 41 + · ╰─┤ crates/mdbook-permalinks/src/tests/url.md does not exist 42 + · │ crates/mdbook-permalinks/src/tests/url/index.md does not exist 43 + · │ crates/mdbook-permalinks/src/tests/url/README.md does not exist 44 + · │ crates/mdbook-permalinks/src/tests/url does not exist 34 45 35 46 │ [](https://example.org/git/tree/HEAD/LICENSE-GPL.md) 36 47 · ──────────────────────────┬───────────────────────── 37 - · ╰── LICENSE-GPL.md 48 + · ╰── LICENSE-GPL.md does not exist 38 49 39 50 ╰────
-13
crates/mdbook-permalinks/src/tests/snaps/_stderr.not-checked-in.snap
··· 1 - --- 2 - source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 105 4 - expression: report 5 - --- 6 - warning: paths outside of repository 7 - ╭─[crates/mdbook-permalinks/src/tests/paths.md:33:1] 8 - 9 - │ [](//LICENSE-MIT.md) 10 - · ──────────┬───────── 11 - · ╰── resolves to /LICENSE-MIT.md 12 - 13 - ╰────
+6 -1
crates/mdbook-permalinks/src/tests/snaps/paths.snap
··· 1 1 --- 2 2 source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 61 4 3 expression: output 5 4 --- 6 5 # relative paths ··· 38 37 [](//LICENSE-MIT.md) 39 38 40 39 ![](shinjuku.jpg) 40 + 41 + # gitignored 42 + 43 + [](/target/.rustc_info.json) 44 + 45 + [](/target/debug)
+36 -1
crates/mdbook-permalinks/src/vcs.rs
··· 68 68 } 69 69 }; 70 70 71 - Ok(Ok(Self { root, link })) 71 + Ok(Ok(Self { root, repo, link })) 72 + } 73 + 74 + pub fn try_file(&self, file: &Url) -> Result<String, PathError> { 75 + let Some(path) = self.root.make_relative(file) else { 76 + return Err(PathError::Unreachable); 77 + }; 78 + 79 + if path.starts_with("../") { 80 + return Err(PathError::NotInRepo); 81 + } 82 + 83 + if file 84 + .to_file_path() 85 + .expect("should be a file: url") 86 + .symlink_metadata() 87 + .is_ok() 88 + { 89 + if !self.repo.is_path_ignored(&path).unwrap_or(false) { 90 + Ok(path) 91 + } else { 92 + Err(PathError::Ignored) 93 + } 94 + } else { 95 + Err(PathError::Unreachable) 96 + } 72 97 } 98 + } 99 + 100 + #[derive(Debug, Clone, Copy, thiserror::Error)] 101 + pub enum PathError { 102 + #[error("does not exist")] 103 + Unreachable, 104 + #[error("is ignored by git")] 105 + Ignored, 106 + #[error("is not in repo")] 107 + NotInRepo, 73 108 } 74 109 75 110 pub trait PermalinkFormat {
+12 -15
crates/mdbook-rustdoc-links/src/tests.rs
··· 13 13 resolver::Resolver, 14 14 }; 15 15 16 - struct TestOutput { 16 + struct Fixture { 17 17 pages: Pages<'static, Url>, 18 18 env: Environment, 19 19 } 20 20 21 21 #[fixture] 22 22 #[once] 23 - fn test_output() -> TestOutput { 23 + fn fixture() -> Fixture { 24 24 let client = Config { 25 25 rust_analyzer: Some("cargo run --package util-rust-analyzer -- analyzer".into()), 26 26 ..Default::default() ··· 53 53 client.stop().await 54 54 }); 55 55 56 - TestOutput { env, pages } 56 + Fixture { env, pages } 57 57 } 58 58 59 - fn assert_output(doc: TestDocument, TestOutput { pages, env }: &TestOutput) -> Result<()> { 59 + fn assert_output(doc: TestDocument, Fixture { pages, env }: &Fixture) -> Result<()> { 60 60 let output = pages.emit(&doc.url(), &env.emit_config())?; 61 61 portable_snapshots!().test(|| insta::assert_snapshot!(doc.name(), output))?; 62 62 Ok(()) 63 63 } 64 64 65 - fn assert_report(doc: TestDocument, TestOutput { pages, .. }: &TestOutput) -> Result<()> { 65 + fn assert_report(doc: TestDocument, Fixture { pages, .. }: &Fixture) -> Result<()> { 66 66 let report = pages 67 67 .reporter() 68 68 .level(log::LevelFilter::Info) ··· 77 77 Ok(()) 78 78 } 79 79 80 - fn assert_whitespace_unchanged( 81 - doc: TestDocument, 82 - TestOutput { pages, env }: &TestOutput, 83 - ) -> Result<()> { 80 + fn assert_whitespace_unchanged(doc: TestDocument, Fixture { pages, env }: &Fixture) -> Result<()> { 84 81 let output = pages.emit(&doc.url(), &env.emit_config())?; 85 82 86 83 let changed_lines = TextDiff::from_words(doc.content, &output) ··· 112 109 113 110 #[rstest] 114 111 $(#[case(test_document!($path))])* 115 - fn assert_outputs(#[case] doc: TestDocument, test_output: &TestOutput) -> Result<()> { 116 - assert_output(doc, test_output) 112 + fn assert_outputs(#[case] doc: TestDocument, fixture: &Fixture) -> Result<()> { 113 + assert_output(doc, fixture) 117 114 } 118 115 119 116 #[rstest] 120 117 $(#[case(test_document!($path))])* 121 - fn assert_reports(#[case] doc: TestDocument, test_output: &TestOutput) -> Result<()> { 122 - assert_report(doc, test_output) 118 + fn assert_reports(#[case] doc: TestDocument, fixture: &Fixture) -> Result<()> { 119 + assert_report(doc, fixture) 123 120 } 124 121 125 122 #[rstest] 126 123 $(#[case(test_document!($path))])* 127 - fn check_whitespace(#[case] doc: TestDocument, test_output: &TestOutput) -> Result<()> { 128 - assert_whitespace_unchanged(doc, test_output) 124 + fn check_whitespace(#[case] doc: TestDocument, fixture: &Fixture) -> Result<()> { 125 + assert_whitespace_unchanged(doc, fixture) 129 126 } 130 127 }; 131 128 }