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.

refactor: update all logging messages

Tony Wu e1fe4c1e 26666424

+1738 -978
+1
Cargo.lock
··· 1510 1510 "cargo_toml", 1511 1511 "clap", 1512 1512 "dirs", 1513 + "futures-util", 1513 1514 "insta", 1514 1515 "lsp-types", 1515 1516 "mdbook-markdown",
+1
Cargo.toml
··· 22 22 assert_cmd = "2.0.16" 23 23 cargo-run-bin = { version = "1.7.4", default-features = false } 24 24 clap = { version = "4.5.31", features = ["derive"] } 25 + futures-util = { version = "0.3.31", default-features = false } 25 26 insta = { version = "1.40.0", features = ["yaml", "filters"] } 26 27 mdbook-markdown = { version = "0.5.1" } 27 28 mdbook-preprocessor = { version = "0.5.1" }
+10 -6
crates/mdbook-permalinks/src/diagnostic.rs
··· 14 14 }; 15 15 16 16 impl Environment { 17 - pub fn report_issues<'a, F>(&'a self, content: &'a Pages<'a>, statuses: F) -> Reporter<'a> 17 + pub fn reporter<'a, F>(&'a self, content: &'a Pages<'a>, statuses: F) -> Reporter<'a> 18 18 where 19 19 F: Fn(&'a LinkStatus) -> bool, 20 20 { ··· 145 145 impl Issue for LinkStatus { 146 146 fn level(&self) -> Level { 147 147 match self { 148 - Self::Ignored => Level::DEBUG, 149 - Self::Unchanged => Level::DEBUG, 150 - Self::Rewritten => Level::INFO, 151 - Self::Permalink => Level::INFO, 148 + Self::Ignored => Level::TRACE, 149 + Self::Unchanged => Level::TRACE, 150 + Self::Rewritten => Level::DEBUG, 151 + Self::Permalink => Level::DEBUG, 152 152 Self::Unreachable(..) => Level::WARN, 153 - Self::Error(..) => Level::WARN, 153 + Self::Error(..) => Level::ERROR, 154 154 } 155 + } 156 + 157 + fn title(&self) -> impl std::fmt::Display { 158 + self 155 159 } 156 160 } 157 161
+77 -45
crates/mdbook-permalinks/src/link.rs
··· 1 1 use std::{fmt::Debug, ops::Range}; 2 2 3 3 use mdbook_markdown::pulldown_cmark::{CowStr, Event, LinkType, Tag, TagEnd}; 4 + use tracing::{debug, trace}; 4 5 use url::Url; 5 6 6 7 #[derive(Debug, Default, Clone, thiserror::Error)] 7 8 pub enum LinkStatus { 8 9 #[default] 9 - #[error("links ignored")] 10 + #[error("link ignored")] 10 11 Ignored, 11 12 12 13 #[error("linking to book page or file")] 13 14 Unchanged, 14 15 #[error("linking to book page or file, rewritten as paths")] 15 16 Rewritten, 16 - #[error("links converted to permalinks")] 17 + #[error("link converted to permalink")] 17 18 Permalink, 18 19 19 - #[error("links inaccessible")] 20 + #[error("link inaccessible")] 20 21 Unreachable(Vec<(Url, PathStatus)>), 21 22 22 23 #[error("error encountered: {0}")] ··· 46 47 pub status: LinkStatus, 47 48 pub span: Range<usize>, 48 49 pub link: CowStr<'a>, 49 - pub hint: ContentTypeHint, 50 + pub hint: ContentHint, 50 51 pub title: CowStr<'a>, 51 52 } 52 53 53 - #[derive(Clone, Copy, PartialEq, Eq)] 54 - pub enum ContentTypeHint { 54 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 55 + pub enum ContentHint { 55 56 Tree, 56 57 Raw, 57 58 } ··· 79 80 } 80 81 } 81 82 82 - impl RelativeLink<'_> { 83 - fn emit(&self) -> Tag<'_> { 83 + impl<'a> RelativeLink<'a> { 84 + #[inline] 85 + pub fn rewritten(&mut self, link: impl Into<CowStr<'a>>) { 86 + self.status = LinkStatus::Rewritten; 87 + self.update(link); 88 + } 89 + 90 + #[inline] 91 + pub fn permalink(&mut self, link: impl Into<CowStr<'a>>) { 92 + self.status = LinkStatus::Permalink; 93 + self.update(link); 94 + } 95 + 96 + #[inline] 97 + fn update(&mut self, link: impl Into<CowStr<'a>>) { 98 + let old = &*self.link.clone(); 99 + self.link = link.into(); 100 + debug!(status = ?self.status, ?old, new = ?&*self.link); 101 + } 102 + 103 + #[inline] 104 + pub fn unchanged(&mut self) { 105 + self.status = LinkStatus::Unchanged; 106 + debug!(status = ?self.status, link = ?&*self.link); 107 + } 108 + 109 + #[inline] 110 + pub fn unreachable(&mut self, errors: Vec<(Url, PathStatus)>) { 111 + self.status = LinkStatus::Unreachable(errors); 112 + debug!(status = ?self.status, link = ?&*self.link); 113 + } 114 + 115 + fn emit(&self) -> Tag<'a> { 84 116 match self.hint { 85 - ContentTypeHint::Tree => Tag::Link { 117 + ContentHint::Tree => Tag::Link { 86 118 link_type: LinkType::Inline, 87 119 dest_url: self.link.clone(), 88 120 title: self.title.clone(), 89 121 id: CowStr::Borrowed(""), 90 122 }, 91 - ContentTypeHint::Raw => Tag::Image { 123 + ContentHint::Raw => Tag::Image { 92 124 link_type: LinkType::Inline, 93 125 dest_url: self.link.clone(), 94 126 title: self.title.clone(), ··· 97 129 } 98 130 } 99 131 100 - fn will_emit(&self) -> Option<ContentTypeHint> { 101 - if matches!(self.status, LinkStatus::Permalink | LinkStatus::Rewritten) { 102 - Some(self.hint) 103 - } else { 104 - None 132 + fn will_emit(&self) -> Option<ContentHint> { 133 + match self.status { 134 + LinkStatus::Ignored => None, 135 + LinkStatus::Unchanged => None, 136 + LinkStatus::Rewritten => Some(self.hint), 137 + LinkStatus::Permalink => Some(self.hint), 138 + LinkStatus::Unreachable(_) => None, 139 + LinkStatus::Error(_) => None, 105 140 } 106 141 } 107 142 } 108 143 109 144 pub struct EmitLinkSpan<'a> { 110 145 iter: std::slice::Iter<'a, LinkText<'a>>, 111 - opened: Vec<ContentTypeHint>, 146 + opened: Vec<ContentHint>, 112 147 } 113 148 114 149 impl<'a> Iterator for EmitLinkSpan<'a> { ··· 117 152 fn next(&mut self) -> Option<Self::Item> { 118 153 for next in self.iter.by_ref() { 119 154 match next { 155 + LinkText::Link(link) => { 156 + let span = &link.span; 157 + match (link.will_emit(), self.opened.is_empty()) { 158 + (Some(usage), top_level) => { 159 + self.opened.push(usage); 160 + let link = link.emit(); 161 + trace!(?span, ?link, "{}", if top_level { ">" } else { ">>" }); 162 + return Some(Event::Start(link)); 163 + } 164 + (None, false) => { 165 + let link = link.emit(); 166 + trace!(?span, ?link, ">│ skipped, link in link"); 167 + return Some(Event::Start(link)); 168 + } 169 + (None, true) => { 170 + trace!(?span, "│ skipped"); 171 + continue; 172 + } 173 + }; 174 + } 120 175 LinkText::Text(text) => { 121 176 match (text, self.opened.last()) { 122 - (Event::End(TagEnd::Link), Some(ContentTypeHint::Tree)) => { 177 + (Event::End(TagEnd::Link | TagEnd::Image), Some(..)) => { 123 178 self.opened.pop(); 124 - return Some(text.clone()); 125 - } 126 - (Event::End(TagEnd::Image), Some(ContentTypeHint::Raw)) => { 127 - self.opened.pop(); 179 + let top_level = self.opened.is_empty(); 180 + trace!(?text, "{}", if top_level { "<" } else { "<<" }); 128 181 return Some(text.clone()); 129 182 } 130 183 (Event::End(TagEnd::Link | TagEnd::Image), None) => { 131 - // skip this end tag because the link was skipped 184 + trace!("│ skipped"); 132 185 continue; 133 186 } 134 187 _ => { 188 + let top_level = self.opened.len() == 1; 189 + trace!(?text, "{}", if top_level { "│" } else { " │" }); 135 190 return Some(text.clone()); 136 191 } 137 192 }; 138 193 } 139 - LinkText::Link(link) => { 140 - match (link.will_emit(), self.opened.is_empty()) { 141 - (Some(usage), _) => { 142 - self.opened.push(usage); 143 - return Some(Event::Start(link.emit())); 144 - } 145 - (None, false) => { 146 - return Some(Event::Start(link.emit())); 147 - } 148 - (None, true) => { 149 - continue; 150 - } 151 - }; 152 - } 153 194 }; 154 195 } 155 196 None ··· 175 216 Some((iter, span)) 176 217 } 177 218 } 178 - 179 - impl Debug for ContentTypeHint { 180 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 181 - match self { 182 - Self::Tree => f.write_str("tree"), 183 - Self::Raw => f.write_str("raw"), 184 - } 185 - } 186 - }
+356 -213
crates/mdbook-permalinks/src/main.rs
··· 1 + #![warn(clippy::unwrap_used)] 2 + 1 3 use std::{ 2 4 collections::{HashMap, HashSet}, 3 5 fmt::Debug, 4 6 str::FromStr, 5 7 }; 6 8 7 - use anyhow::{Context, Result, anyhow}; 9 + use anyhow::{Context, Result}; 8 10 use git2::Repository; 9 11 use mdbook_markdown::pulldown_cmark; 10 12 use mdbook_preprocessor::{Preprocessor, PreprocessorContext, book::Book}; 11 13 use serde::Deserialize; 12 - use tap::{Pipe, Tap, TapFallible}; 13 - use tracing::{level_filters::LevelFilter, warn}; 14 + use tap::Tap; 15 + use tracing::{Level, debug, info, info_span, span::EnteredSpan, trace, warn}; 14 16 use url::Url; 15 17 16 18 use mdbookkit::{ 17 19 book::{BookConfigHelper, BookHelper, book_from_stdin}, 18 20 diagnostics::Issue, 19 - emit_debug, emit_warning, 20 - error::OnWarning, 21 + emit_debug, emit_error, 22 + error::{ExitProcess, OnWarning}, 21 23 logging::Logging, 24 + ticker, ticker_item, 25 + url::{ExpectUrl, UrlFromPath}, 22 26 }; 23 27 24 28 use self::{ 25 - link::{LinkStatus, PathStatus, RelativeLink}, 29 + link::{ContentHint, LinkStatus, PathStatus, RelativeLink}, 26 30 page::Pages, 27 31 vcs::{Permalink, PermalinkFormat}, 28 32 }; ··· 34 38 mod tests; 35 39 mod vcs; 36 40 41 + fn main() -> Result<()> { 42 + Logging::default().init(); 43 + let _span = info_span!({ env!("CARGO_PKG_NAME") }).entered(); 44 + let Program { command } = clap::Parser::parse(); 45 + match command { 46 + None => mdbook().exit(emit_error!()), 47 + Some(Command::Supports { .. }) => Ok(()), 48 + #[cfg(feature = "_testing")] 49 + Some(Command::Describe) => { 50 + print!("{}", mdbookkit::docs::describe_preprocessor::<Config>()?); 51 + Ok(()) 52 + } 53 + } 54 + } 55 + 56 + fn mdbook() -> Result<()> { 57 + let (ctx, book) = book_from_stdin().context("Failed to read from mdBook")?; 58 + Permalinks.run(&ctx, book)?.to_stdout(&ctx)?; 59 + Ok(()) 60 + } 61 + 62 + #[derive(clap::Parser, Debug, Clone)] 63 + struct Program { 64 + #[command(subcommand)] 65 + command: Option<Command>, 66 + } 67 + 68 + #[derive(clap::Subcommand, Debug, Clone)] 69 + enum Command { 70 + #[clap(hide = true)] 71 + Supports { renderer: String }, 72 + #[cfg(feature = "_testing")] 73 + #[clap(hide = true)] 74 + Describe, 75 + } 76 + 37 77 struct Permalinks; 38 78 39 79 impl Preprocessor for Permalinks { 40 80 fn name(&self) -> &str { 41 - env!("CARGO_PKG_NAME") 81 + PREPROCESSOR_NAME 42 82 } 43 83 44 84 fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> { 45 85 match Environment::new(ctx) { 46 - Ok(Ok(env)) => env.run(ctx, book), 86 + Ok(Ok(env)) => { 87 + debug!("{env:#?}"); 88 + env.run(ctx, book) 89 + } 47 90 Ok(Err(err)) => { 48 - warn!("{:?}", err.context("preprocessor will be disabled")); 91 + warn!("{:?}", err.context("Preprocessor will be disabled")); 49 92 Ok(book) 50 93 } 51 - Err(err) => Err(err).context(format!( 52 - "failed to initialize preprocessor `{}`", 53 - self.name() 54 - )), 94 + Err(err) => Err(err).context("Failed to initialize"), 55 95 } 56 96 } 57 97 } ··· 78 118 let mut content = Pages::new(self.markdown); 79 119 80 120 for (path, ch) in book.iter_chapters() { 81 - let path = path.to_string_lossy(); 82 - let url = self 83 - .root_dir 84 - .join(&path) 85 - .context("could not read path as a url")?; 121 + let path = path 122 + .to_str() 123 + .context("only Unicode characters are supported") 124 + .with_context(|| format!("{path:?} contains unsupported characters"))?; 125 + let url = self.root_dir.join(path).expect_url(); 86 126 content 87 127 .insert(url, &ch.content) 88 - .with_context(|| format!("failed to parse {path}"))?; 128 + .with_context(|| format!("Failed to parse {path:?}"))?; 89 129 } 90 130 91 131 self.resolve(&mut content); 92 - 93 - let mut result = book 94 - .iter_chapters() 95 - .filter_map(|(path, _)| { 96 - let url = self.root_dir.join(&path.to_string_lossy()).ok()?; 97 - content 98 - .emit(&url) 99 - .tap_err(emit_warning!()) 100 - .ok() 101 - .map(|output| (path.clone(), output.to_string())) 102 - }) 103 - .collect::<HashMap<_, _>>(); 104 132 105 133 let status = self 106 - .report_issues(&content, |_| true) 107 - .names(|url| self.rel_path(url)) 108 - .level(LevelFilter::WARN) 134 + .reporter(&content, |_| true) 135 + .name_display(|url| self.rel_path(url)) 109 136 .build() 110 137 .to_stderr() 111 138 .to_status(); 112 139 140 + content.log_stats(); 141 + 142 + // bail before emitting changes 143 + self.config.fail_on_warnings.check(status.level())?; 144 + 145 + let mut result = book 146 + .iter_chapters() 147 + .map(|(path, _)| { 148 + let _span = info_span!("emit", key = ?path).entered(); 149 + debug!("generating output"); 150 + let key = path.to_str().expect("paths have been checked"); 151 + let url = self.root_dir.join(key).expect_url(); 152 + let out = content.emit(&url).context("Error generating output")?; 153 + Ok((path.clone(), out)) 154 + }) 155 + .collect::<Result<HashMap<_, _>>>()?; 156 + 113 157 book.for_each_text_mut(|path, content| { 114 158 if let Some(output) = result.remove(path) { 115 159 *content = output; 116 160 } 117 161 }); 118 162 119 - self.config.fail_on_warnings.check(status.level())?; 163 + if status.level() <= Level::WARN { 164 + warn!("Finished with problems"); 165 + } else { 166 + info!("Finished"); 167 + } 120 168 121 169 Ok(book) 122 170 } ··· 126 174 fn resolve(&self, content: &mut Pages<'_>) { 127 175 self.validate(); 128 176 129 - let book_pages = &content.paths(&self.root_dir); 177 + let page_paths = &content.paths(&self.root_dir); 178 + 179 + let ticker = ticker!(Level::INFO, "process", "processing links").entered(); 130 180 131 181 for (base, link) in content.links_mut() { 132 - let file = if let Some(link) = link.link.strip_prefix('/') { 182 + let file_url = match if let Some(link) = link.link.strip_prefix('/') { 133 183 self.vcs.root.join(link) 134 184 } else { 135 185 base.join(&link.link) 136 - } 137 - .context("could not derive url") 138 - .tap_err(emit_debug!()); 139 - 140 - let Ok(file_url) = file else { 141 - link.status = LinkStatus::Ignored; 142 - continue; 186 + } { 187 + Ok(url) => url, 188 + Err(e) => { 189 + debug!("ignoring unparsable link {:?}: {e}", &*link.link); 190 + link.status = LinkStatus::Ignored; 191 + continue; 192 + } 143 193 }; 144 194 145 195 let env = self; 146 196 let page_url = base.as_ref(); 147 197 148 - Resolver { 149 - link, 150 - page_url, 151 - file_url, 152 - book_pages, 153 - env, 198 + if let Some(book) = &env.config.book_url 199 + && let Some(path) = book.0.make_relative(&file_url) 200 + && !path.starts_with("../") 201 + { 202 + let dest = ResolveBook { 203 + link, 204 + file_url, 205 + page_url, 206 + page_paths, 207 + path, 208 + }; 209 + let _span = dest.span(&ticker); 210 + self.resolve_book(dest); 211 + } else if let Some((path, hint)) = env.vcs.link.to_path(&file_url) 212 + && let Ok(url) = env.vcs.root.join(&path) 213 + { 214 + let (_, url_suffix) = UrlSuffix::take(file_url); 215 + let dest = ResolveFile { 216 + hint, 217 + url_suffix, 218 + is_vcs: true, 219 + file_url: url, 220 + page_url, 221 + page_paths, 222 + link, 223 + }; 224 + let _span = dest.span(&ticker); 225 + self.resolve_file(dest); 226 + } else if file_url.scheme() == "file" { 227 + let (file_url, url_suffix) = UrlSuffix::take(file_url); 228 + let dest = ResolveFile { 229 + hint: link.hint, 230 + url_suffix, 231 + is_vcs: false, 232 + file_url, 233 + page_url, 234 + page_paths, 235 + link, 236 + }; 237 + let _span = dest.span(&ticker); 238 + self.resolve_file(dest); 154 239 } 155 - .resolve(); 156 240 } 157 241 } 158 242 159 - #[inline] 160 - fn validate(&self) { 161 - debug_assert!( 162 - self.root_dir.as_str().ends_with('/'), 163 - "book_src should have a trailing slash, got {}", 164 - self.root_dir 165 - ); 166 - debug_assert!( 167 - self.vcs.root.as_str().ends_with('/'), 168 - "vcs_root should have a trailing slash, got {}", 169 - self.vcs.root 170 - ); 171 - } 172 - 173 - fn new(book: &PreprocessorContext) -> Result<Result<Self>> { 174 - let config = book 175 - .config 176 - .preprocessor(&[PREPROCESSOR_NAME, "mdbook-link-forever"])?; 177 - 178 - let vcs = match VersionControl::try_from_git(&config, &book.config) { 179 - Ok(Ok(vcs)) => vcs, 180 - Ok(Err(err)) => return Ok(Err(err)), 181 - Err(err) => return Err(err), 182 - }; 183 - 184 - let markdown = book.config.markdown_options(); 185 - 186 - let root_dir = book 187 - .root 188 - .canonicalize() 189 - .context("failed to locate book root")? 190 - .join(&book.config.book.src) 191 - .pipe(Url::from_directory_path) 192 - .map_err(|_| anyhow!("book `src` should be a valid absolute path"))?; 193 - 194 - Ok(Ok(Self { 195 - vcs, 196 - root_dir, 197 - markdown, 198 - config, 199 - })) 200 - } 201 - } 202 - 203 - #[must_use] 204 - struct Resolver<'a, 'r> { 205 - link: &'a mut RelativeLink<'r>, 206 - file_url: Url, 207 - page_url: &'a Url, 208 - book_pages: &'a HashSet<String>, 209 - env: &'a Environment, 210 - } 211 - 212 - impl Resolver<'_, '_> { 213 - fn resolve(self) { 214 - if let Some(book) = &self.env.config.book_url 215 - && let Some(path) = book.0.make_relative(&self.file_url) 216 - && !path.starts_with("../") 217 - { 218 - self.resolve_book(path) 219 - } else { 220 - self.resolve_file() 243 + fn resolve_file( 244 + &self, 245 + ResolveFile { 246 + file_url, 247 + page_url, 248 + page_paths, 249 + hint, 250 + url_suffix, 251 + is_vcs, 252 + link, 253 + }: ResolveFile, 254 + ) { 255 + if url_suffix.query.is_some() || url_suffix.fragment.is_some() { 256 + trace!(?url_suffix); 221 257 } 222 - } 223 258 224 - fn resolve_file(self) { 225 - let Self { 226 - link, 227 - page_url, 228 - file_url, 229 - env, 230 - .. 231 - } = self; 232 - 233 - let (file_url, hint, suffix, is_vcs) = if let Some((path, hint)) = 234 - env.vcs.link.to_path(&file_url) 235 - && let Ok(url) = env.vcs.root.join(&path) 236 - { 237 - let (_, suffix) = UrlSuffix::take(file_url); 238 - (url, hint, suffix, true) 239 - } else if file_url.scheme() == "file" { 240 - let (url, suffix) = UrlSuffix::take(file_url); 241 - (url, link.hint, suffix, false) 242 - } else { 243 - return; 244 - }; 245 - 246 - let relative_to_repo = match self.env.vcs.try_file(&file_url) { 259 + let relative_to_repo = match self.vcs.try_file(&file_url) { 247 260 Ok(path) => path, 248 261 Err(err) => { 249 - link.status = LinkStatus::Unreachable(vec![(file_url, err)]); 262 + link.unreachable(vec![(file_url, err)]); 250 263 return; 251 264 } 252 265 }; 253 266 254 - let relative_to_book = env 267 + let relative_to_book = self 255 268 .root_dir 256 269 .make_relative(&file_url) 257 270 .expect("should be a file"); 258 271 259 - let should_link = is_vcs 260 - || relative_to_book.starts_with("../") 261 - || relative_to_book.ends_with(".md") && !self.book_pages.contains(&relative_to_book) 262 - || (env.config.always_link) 263 - .iter() 264 - .any(|suffix| file_url.path().ends_with(suffix)); 272 + trace!(?relative_to_book); 273 + 274 + let should_link = is_vcs.tap(|r| trace!(is_vcs = r)) 275 + || relative_to_book 276 + .starts_with("../") 277 + .tap(|r| trace!(not_in_book = r)) 278 + || (relative_to_book.ends_with(".md") && !page_paths.contains(&relative_to_book)) 279 + .tap(|r| trace!(book_assets = r)) 280 + || (self.config.always_link.iter()) 281 + .any(|suffix| file_url.path().ends_with(suffix)) 282 + .tap(|r| trace!(always_link = r)); 283 + 284 + trace!(?should_link); 265 285 266 286 if !should_link { 267 287 if link.link.starts_with('/') { 268 - // mdbook doesn't support absolute paths like VS Code does 269 - link.link = page_url 270 - .make_relative(&suffix.restored(file_url)) 271 - .expect("both should be file: urls") 272 - .into(); 273 - link.status = LinkStatus::Rewritten; 288 + // mdBook doesn't support absolute paths like VS Code does 289 + let rewritten = page_url 290 + .make_relative(&url_suffix.restored(file_url)) 291 + .expect("both should be file: urls"); 292 + link.rewritten(rewritten); 274 293 } else { 275 - link.status = LinkStatus::Unchanged; 294 + link.unchanged(); 276 295 } 277 - return; 278 - } 279 - 280 - match env.vcs.link.to_link(&relative_to_repo.path, hint) { 281 - Ok(href) => { 282 - link.link = suffix.restored(href).as_str().to_owned().into(); 283 - link.status = LinkStatus::Permalink; 296 + } else { 297 + match self.vcs.link.to_link(&relative_to_repo.path, hint) { 298 + Ok(href) => { 299 + link.permalink(url_suffix.restored(href).as_str().to_owned()); 300 + } 301 + Err(err) => { 302 + link.status = LinkStatus::Error(format!("{err}")); 303 + debug!(status = ?link.status, link = ?&*link.link); 304 + } 284 305 } 285 - Err(err) => link.status = LinkStatus::Error(format!("{err}")), 286 306 } 287 307 } 288 308 289 309 /// Check hard-coded URLs to book content 290 - fn resolve_book(self, path: String) { 291 - let Self { 310 + fn resolve_book( 311 + &self, 312 + ResolveBook { 292 313 file_url, 293 314 page_url, 315 + page_paths, 316 + path, 294 317 link, 295 - .. 296 - } = self; 297 - 318 + }: ResolveBook, 319 + ) { 298 320 let path = { 299 321 let mut path = path; 322 + trace!(?path); 300 323 if let Some(idx) = path.find('#') { 301 - path.truncate(idx) 324 + path.truncate(idx); 325 + trace!(?path, "removing fragment"); 302 326 }; 303 327 if let Some(idx) = path.find('?') { 304 - path.truncate(idx) 328 + path.truncate(idx); 329 + trace!(?path, "removing query"); 305 330 }; 331 + trace!(?path); 306 332 path 307 333 }; 308 334 ··· 310 336 311 337 let is_index = path.is_empty() || path.ends_with('/'); 312 338 339 + trace!(is_index); 340 + 313 341 let try_pages = { 314 - let path = path.strip_suffix(".html").unwrap_or(&path); 342 + let path = path 343 + .strip_suffix(".html") 344 + .inspect(|_| trace!("removing .html suffix")) 345 + .unwrap_or(&path); 315 346 // one does not simply avoid trailing slash issues... 316 347 // https://github.com/slorber/trailing-slash-guide 317 348 if is_index { ··· 333 364 }; 334 365 335 366 for page in try_pages { 336 - let file_url = (self.env.root_dir) 367 + trace!("trying book page {page:?}"); 368 + 369 + let file_url = (self.root_dir) 337 370 .join(page) 338 371 .expect("should be a valid url") 339 372 .tap_mut(|u| u.set_query(file_url.query())) 340 373 .tap_mut(|u| u.set_fragment(file_url.fragment())); 341 374 342 - if self.book_pages.contains(page) { 343 - link.link = page_url 375 + if page_paths.contains(page) { 376 + let rewritten = page_url 344 377 .make_relative(&file_url) 345 - .expect("both should be file: urls") 346 - .into(); 347 - link.status = LinkStatus::Rewritten; 378 + .expect("both should be file: urls"); 379 + link.rewritten(rewritten); 348 380 return; 349 381 } 350 382 383 + trace!("not found: {file_url}"); 351 384 not_found.push((file_url, PathStatus::NotInBook)); 352 385 } 353 386 354 387 if !is_index { 355 - let try_file = (self.env.root_dir) 356 - .join(&path) 357 - .expect("should be a valid url"); 388 + let try_file = self.root_dir.join(&path).expect("should be a valid url"); 358 389 359 - match self.env.vcs.try_file(&try_file) { 390 + match self.vcs.try_file(&try_file) { 360 391 Ok(result) if !result.metadata.is_dir() => { 361 392 let file_url = try_file 362 393 .tap_mut(|u| u.set_query(file_url.query())) 363 394 .tap_mut(|u| u.set_fragment(file_url.fragment())); 364 395 365 - link.link = page_url 396 + let rewritten = page_url 366 397 .make_relative(&file_url) 367 - .expect("both should be file: urls") 368 - .into(); 369 - link.status = LinkStatus::Rewritten; 398 + .expect("both should be file: urls"); 399 + 400 + link.rewritten(rewritten); 370 401 371 402 return; 372 403 } ··· 381 412 } 382 413 } 383 414 384 - link.status = LinkStatus::Unreachable(not_found); 415 + link.unreachable(not_found); 416 + } 417 + 418 + #[inline] 419 + fn validate(&self) { 420 + debug_assert!( 421 + self.root_dir.as_str().ends_with('/'), 422 + "book_src should have a trailing slash, got {}", 423 + self.root_dir 424 + ); 425 + debug_assert!( 426 + self.vcs.root.as_str().ends_with('/'), 427 + "vcs_root should have a trailing slash, got {}", 428 + self.vcs.root 429 + ); 430 + } 431 + 432 + fn new(book: &PreprocessorContext) -> Result<Result<Self>> { 433 + let config = (book.config) 434 + .preprocessor(&[PREPROCESSOR_NAME, "mdbook-link-forever"]) 435 + .inspect(emit_debug!("{:#?}")) 436 + .context("Failed to read preprocessor config from book.toml")?; 437 + 438 + let vcs = match VersionControl::try_from_git(&config, &book.config) { 439 + Ok(Ok(vcs)) => vcs, 440 + Ok(Err(err)) => return Ok(Err(err)), 441 + Err(err) => return Err(err), 442 + }; 443 + 444 + let markdown = book.config.markdown_options(); 445 + 446 + let root_dir = (book.root) 447 + .canonicalize() 448 + .context("Failed to locate book root")? 449 + .join(&book.config.book.src) 450 + .to_directory_url(); 451 + 452 + Ok(Ok(Self { 453 + vcs, 454 + root_dir, 455 + markdown, 456 + config, 457 + })) 458 + } 459 + } 460 + 461 + struct ResolveFile<'a, 'r> { 462 + file_url: Url, 463 + page_url: &'a Url, 464 + page_paths: &'a HashSet<String>, 465 + hint: ContentHint, 466 + url_suffix: UrlSuffix, 467 + is_vcs: bool, 468 + link: &'a mut RelativeLink<'r>, 469 + } 470 + 471 + struct ResolveBook<'a, 'r> { 472 + file_url: Url, 473 + page_url: &'a Url, 474 + page_paths: &'a HashSet<String>, 475 + path: String, 476 + link: &'a mut RelativeLink<'r>, 477 + } 478 + 479 + impl<'a, 'r> ResolveFile<'a, 'r> { 480 + #[inline] 481 + fn span(&self, parent: impl Into<Option<tracing::Id>>) -> EnteredSpan { 482 + let Self { 483 + file_url, 484 + page_url, 485 + hint, 486 + link, 487 + .. 488 + } = self; 489 + if tracing::enabled!(Level::DEBUG) { 490 + ticker_item! { 491 + parent, Level::INFO, "file_link", 492 + %file_url, %page_url, ?hint, 493 + "{:?}", &*link.link 494 + } 495 + } else { 496 + ticker_item!(parent, Level::INFO, "file_link", "{:?}", &*link.link) 497 + } 498 + .entered() 499 + } 500 + } 501 + 502 + impl<'a, 'r> ResolveBook<'a, 'r> { 503 + #[inline] 504 + fn span(&self, parent: impl Into<Option<tracing::Id>>) -> EnteredSpan { 505 + let Self { 506 + file_url, 507 + page_url, 508 + path, 509 + link, 510 + .. 511 + } = self; 512 + if tracing::enabled!(Level::DEBUG) { 513 + ticker_item! { 514 + parent, Level::INFO, "book_link", 515 + %file_url, %page_url, ?path, 516 + "{:?}", &*link.link 517 + } 518 + } else { 519 + ticker_item!(parent, Level::INFO, "book_link", "{:?}", &*link.link) 520 + } 521 + .entered() 385 522 } 386 523 } 387 524 ··· 390 527 /// This is deserialized from book.toml. 391 528 /// 392 529 /// Doc comments for attributes populate the `configuration.md` page in the docs. 393 - #[derive(clap::Parser, Deserialize, Default)] 530 + #[derive(clap::Parser, Deserialize, Debug, Default)] 394 531 #[serde(rename_all = "kebab-case", deny_unknown_fields)] 395 532 struct Config { 396 533 /// Use a custom link format for platforms other than GitHub. ··· 479 616 command: Option<String>, 480 617 } 481 618 482 - #[derive(Debug, Clone)] 619 + #[derive(Clone)] 483 620 struct UrlPrefix(Url); 484 621 485 622 impl From<Url> for UrlPrefix { ··· 510 647 } 511 648 } 512 649 650 + impl Debug for UrlPrefix { 651 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 652 + f.debug_tuple("UrlPrefix") 653 + .field(&format_args!("\"{}\"", self.0)) 654 + .finish() 655 + } 656 + } 657 + 513 658 #[must_use] 659 + #[derive(Debug)] 514 660 struct UrlSuffix { 515 661 query: Option<String>, 516 662 fragment: Option<String>, ··· 542 688 } 543 689 } 544 690 545 - fn main() -> Result<()> { 546 - Logging::default().init(); 547 - let Program { command } = clap::Parser::parse(); 548 - match command { 549 - None => { 550 - let (ctx, book) = book_from_stdin().context("failed to read from mdbook")?; 551 - Permalinks.run(&ctx, book)?.to_stdout(&ctx)?; 552 - Ok(()) 553 - } 554 - Some(Command::Supports { .. }) => Ok(()), 555 - #[cfg(feature = "_testing")] 556 - Some(Command::Describe) => { 557 - print!("{}", mdbookkit::docs::describe_preprocessor::<Config>()?); 558 - Ok(()) 559 - } 691 + impl std::fmt::Debug for Environment { 692 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 693 + let Self { 694 + vcs, 695 + root_dir, 696 + markdown, 697 + config: _, 698 + } = self; 699 + f.debug_struct("Environment") 700 + .field("root_dir", &format_args!("\"{root_dir}\"")) 701 + .field("vcs", &vcs) 702 + .field("markdown", &markdown) 703 + .finish_non_exhaustive() 560 704 } 561 705 } 562 706 563 - #[derive(clap::Parser, Debug, Clone)] 564 - struct Program { 565 - #[command(subcommand)] 566 - command: Option<Command>, 567 - } 568 - 569 - #[derive(clap::Subcommand, Debug, Clone)] 570 - enum Command { 571 - #[clap(hide = true)] 572 - Supports { renderer: String }, 573 - #[cfg(feature = "_testing")] 574 - #[clap(hide = true)] 575 - Describe, 707 + impl std::fmt::Debug for VersionControl { 708 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 709 + let Self { 710 + root, 711 + link, 712 + repo: _, 713 + } = self; 714 + f.debug_struct("VersionControl") 715 + .field("root", &format_args!("\"{root}\"")) 716 + .field("link", &link) 717 + .finish_non_exhaustive() 718 + } 576 719 } 577 720 578 721 static PREPROCESSOR_NAME: &str = env!("CARGO_PKG_NAME");
+65 -23
crates/mdbook-permalinks/src/page.rs
··· 8 8 9 9 use anyhow::{Context, Result, bail}; 10 10 use mdbook_markdown::pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; 11 - use tap::{Pipe, TapFallible}; 11 + use tap::Pipe; 12 + use tracing::{debug, info, instrument, trace}; 12 13 use url::Url; 13 14 14 15 use mdbookkit::{ 15 16 emit_warning, 16 17 markdown::{PatchStream, Spanned}, 18 + plural, 17 19 }; 18 20 19 - use crate::link::{ContentTypeHint, EmitLinkSpan, LinkSpan, LinkStatus, LinkText, RelativeLink}; 21 + use crate::link::{ContentHint, EmitLinkSpan, LinkSpan, LinkStatus, LinkText, RelativeLink}; 20 22 21 23 pub struct Pages<'a> { 22 24 pages: HashMap<Arc<Url>, Page<'a>>, ··· 43 45 .collect() 44 46 } 45 47 48 + #[instrument(level = "debug", "page_read", skip_all)] 46 49 pub fn insert(&mut self, url: Url, source: &'a str) -> Result<&mut Self> { 50 + debug!(path = ?url.path(), "reading file"); 47 51 let stream = Parser::new_ext(source, self.markdown).into_offset_iter(); 48 52 let page = Page::read(source, stream)?; 49 53 self.pages.insert(url.into(), page); 50 54 Ok(self) 51 55 } 52 56 53 - pub fn links(&'_ self) -> impl Iterator<Item = (&'_ Arc<Url>, &'_ RelativeLink<'_>)> { 57 + pub fn links(&self) -> impl Iterator<Item = (&Arc<Url>, &RelativeLink<'a>)> { 54 58 self.pages.iter().flat_map(|(base, page)| { 55 - page.links 56 - .iter() 57 - .flat_map(move |links| links.links().map(move |link| (base, link))) 59 + (page.links.iter()).flat_map(move |links| links.links().map(move |link| (base, link))) 58 60 }) 59 61 } 60 62 61 63 pub fn links_mut(&mut self) -> impl Iterator<Item = (&Arc<Url>, &mut RelativeLink<'a>)> { 62 64 self.pages.iter_mut().flat_map(|(base, page)| { 63 - page.links 64 - .iter_mut() 65 + (page.links.iter_mut()) 65 66 .flat_map(move |links| links.links_mut().map(move |link| (base, link))) 66 67 }) 67 68 } ··· 75 76 Arc<Url>: Borrow<Q>, 76 77 Q: Eq + Hash + Debug + ?Sized, 77 78 { 78 - let page = self.pages.get(key); 79 - let page = page.with_context(|| format!("no such document {key:?}"))?; 80 - page.emit() 79 + self.pages 80 + .get(key) 81 + .with_context(|| format!("No such document {key:?}")) 82 + .inspect_err(emit_warning!()) 83 + .expect("should have document") 84 + .emit() 85 + } 86 + 87 + pub fn log_stats(&self) { 88 + let mut ignored = 0; 89 + let mut unchanged = 0; 90 + let mut rewritten = 0; 91 + let mut permalink = 0; 92 + let mut unreachable = 0; 93 + let mut error = 0; 94 + let mut total = 0; 95 + 96 + for (_, link) in self.links() { 97 + total += 1; 98 + match link.status { 99 + LinkStatus::Ignored => ignored += 1, 100 + LinkStatus::Unchanged => unchanged += 1, 101 + LinkStatus::Rewritten => rewritten += 1, 102 + LinkStatus::Permalink => permalink += 1, 103 + LinkStatus::Unreachable(_) => unreachable += 1, 104 + LinkStatus::Error(_) => error += 1, 105 + } 106 + } 107 + 108 + info!( 109 + "Processed {total}: {permalink}; {rewritten}; {unreachable}; {unchanged}", 110 + total = plural!(total, "link"), 111 + permalink = plural!(permalink, "generated permalink"), 112 + rewritten = plural!(rewritten, "rewritten as paths", "rewritten as paths"), 113 + unreachable = plural!(unreachable, "unreachable path"), 114 + unchanged = plural!(unchanged + ignored + error, "unchanged", "unchanged"), 115 + ); 81 116 } 82 117 } 83 118 ··· 96 131 for (event, span) in stream { 97 132 match event { 98 133 Event::Start(tag @ (Tag::Link { .. } | Tag::Image { .. })) => { 99 - let (usage, link, title) = match tag { 134 + let (hint, link, title) = match tag { 100 135 Tag::Link { 101 136 dest_url, title, .. 102 - } => (ContentTypeHint::Tree, dest_url, title), 137 + } => (ContentHint::Tree, dest_url, title), 103 138 Tag::Image { 104 139 dest_url, title, .. 105 - } => (ContentTypeHint::Raw, dest_url, title), 140 + } => (ContentHint::Raw, dest_url, title), 106 141 _ => unreachable!(), 107 142 }; 143 + 144 + let parent = opened.as_ref().map(|link| link.span()); 145 + trace!(?span, ?parent, ?hint, ">>>"); 146 + trace!(?link, " │ "); 147 + trace!(?title, " │ "); 148 + 108 149 let link = RelativeLink { 109 150 status: LinkStatus::Ignored, 110 151 span, 111 152 link, 112 - hint: usage, 153 + hint, 113 154 title, 114 155 } 115 156 .pipe(LinkText::Link); 157 + 116 158 match opened.as_mut() { 117 159 Some(opened) => opened.0.push(link), 118 160 None => opened = Some(LinkSpan(vec![link])), ··· 120 162 } 121 163 122 164 event @ Event::End(end @ (TagEnd::Link | TagEnd::Image)) => { 123 - let usage = match end { 124 - TagEnd::Link => ContentTypeHint::Tree, 125 - TagEnd::Image => ContentTypeHint::Raw, 126 - _ => unreachable!(), 127 - }; 128 165 let Some(mut items) = opened.take() else { 129 - bail!("unexpected {usage:?} at {span:?}") 166 + debug!(?span, "unexpected {end:?}"); 167 + bail!("Markdown stream malformed at byte position {span:?}"); 130 168 }; 169 + 170 + trace!(?span, "<<<"); 171 + 131 172 items.0.push(LinkText::Text(event)); 173 + 132 174 if &span == items.span() { 133 175 this.links.push(items); 134 176 } else { ··· 138 180 139 181 event => { 140 182 if let Some(link) = opened.as_mut() { 183 + trace!(?span, " │ "); 141 184 link.0.push(LinkText::Text(event)) 142 185 } 143 186 } ··· 152 195 .iter() 153 196 .filter_map(EmitLinkSpan::new) 154 197 .pipe(|stream| PatchStream::new(self.source, stream)) 155 - .into_string() 156 - .tap_err(emit_warning!())? 198 + .into_string()? 157 199 .pipe(Ok) 158 200 } 159 201 }
+5 -3
crates/mdbook-permalinks/src/tests.rs
··· 1 + #![allow(clippy::unwrap_used)] 2 + 1 3 use std::sync::{Arc, Mutex}; 2 4 3 5 use anyhow::Result; ··· 116 118 let Fixture { env, pages } = fixture; 117 119 let env = env.lock().unwrap(); 118 120 let report = env 119 - .report_issues(pages, test) 120 - .level(LevelFilter::DEBUG) 121 - .names(|url| env.rel_path(url)) 121 + .reporter(pages, test) 122 + .level_filter(LevelFilter::TRACE) 123 + .name_display(|url| env.rel_path(url)) 122 124 .build() 123 125 .to_report(); 124 126 drop(env);
+3 -3
crates/mdbook-permalinks/src/tests/snaps/_stderr.ignored.snap
··· 1 1 --- 2 2 source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 121 3 + assertion_line: 127 4 4 expression: report 5 5 --- 6 - info: links ignored 6 + info: link ignored 7 7 ╭─[crates/mdbook-permalinks/src/tests/trailing-slash/index.md:1:1] 8 8 │ <https://github.com/slorber/trailing-slash-guide> 9 9 · ───────────────────────────────────────────────── 10 10 ╰──── 11 11 12 - info: links ignored 12 + info: link ignored 13 13 ╭─[crates/mdbook-permalinks/src/tests/urls.md:33:1] 14 14 15 15 │ [](https://example.org/boo)
+4 -4
crates/mdbook-permalinks/src/tests/snaps/_stderr.permalink.snap
··· 1 1 --- 2 2 source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 121 3 + assertion_line: 127 4 4 expression: report 5 5 --- 6 - info: links converted to permalinks 6 + info: link converted to permalink 7 7 ╭─[crates/mdbook-permalinks/src/tests/paths.md:3:1] 8 8 9 9 │ [](../../../../Cargo.toml) ··· 52 52 53 53 ╰──── 54 54 55 - info: links converted to permalinks 55 + info: link converted to permalink 56 56 ╭─[crates/mdbook-permalinks/src/tests/suffix.md:1:1] 57 57 │ [](/docs/book.toml?branch=default) 58 58 · ─────────────────┬──────────────── ··· 67 67 · ╰── https://example.org/git/tree/v0.0/docs/book.toml?/#/L40-44 68 68 ╰──── 69 69 70 - info: links converted to permalinks 70 + info: link converted to permalink 71 71 ╭─[crates/mdbook-permalinks/src/tests/urls.md:15:1] 72 72 73 73 │ [](https://example.org/git/tree/HEAD/LICENSE-APACHE.md)
+3 -3
crates/mdbook-permalinks/src/tests/snaps/_stderr.unreachable.snap
··· 1 1 --- 2 2 source: crates/mdbook-permalinks/src/tests.rs 3 - assertion_line: 121 3 + assertion_line: 127 4 4 expression: report 5 5 --- 6 - warning: links inaccessible 6 + warning: link inaccessible 7 7 ╭─[crates/mdbook-permalinks/src/tests/paths.md:31:1] 8 8 9 9 │ [](../../Cargo.lock) ··· 31 31 · ╰── target/debug is ignored by git 32 32 ╰──── 33 33 34 - warning: links inaccessible 34 + warning: link inaccessible 35 35 ╭─[crates/mdbook-permalinks/src/tests/urls.md:23:1] 36 36 37 37 │ [](https://example.org/book/tests/urls/)
+100 -64
crates/mdbook-permalinks/src/vcs.rs
··· 3 3 use anyhow::{Context, Result, anyhow, bail}; 4 4 use git2::{DescribeOptions, Repository}; 5 5 use mdbook_preprocessor::config::Config as MDBookConfig; 6 - use tap::{Pipe, Tap, TapFallible}; 7 - use tracing::{debug, info}; 6 + use tap::{Pipe, Tap}; 7 + use tracing::{debug, info, instrument, trace}; 8 8 use url::{Url, form_urlencoded::Serializer as SearchParams}; 9 9 10 - use mdbookkit::emit_debug; 10 + use mdbookkit::{emit_debug, emit_trace, url::UrlFromPath}; 11 11 12 12 use crate::{ 13 13 Config, VersionControl, 14 - link::{ContentTypeHint, PathStatus}, 14 + link::{ContentHint, PathStatus}, 15 15 }; 16 16 17 17 impl VersionControl { 18 + #[instrument(level = "debug", skip_all)] 18 19 pub fn try_from_git(config: &Config, book: &MDBookConfig) -> Result<Result<Self>> { 19 20 let repo = match Repository::open_from_env() 20 - .context("preprocessor requires a git repository to work") 21 - .context("failed to find a git repository") 21 + .context("Preprocessor requires a git repository to work") 22 + .context("Could not find a git repository") 22 23 { 23 24 Ok(repo) => repo, 24 25 Err(err) => return config.fail_on_warnings.adjusted(Ok(Err(err))), ··· 28 29 .workdir() 29 30 .unwrap_or_else(|| repo.commondir()) 30 31 .canonicalize() 31 - .context("failed to locate repo root")? 32 - .pipe(Url::from_directory_path) 33 - .map_err(|_| anyhow!("failed to locate repo root"))?; 32 + .context("Could not locate repo root")? 33 + .to_directory_url(); 34 34 35 35 let Some(reference) = 36 - get_git_head(&repo).context("failed to get a tag or commit id to HEAD")? 36 + get_git_head(&repo).context("Could not get a tag or the commit hash to HEAD")? 37 37 else { 38 38 return config 39 39 .fail_on_warnings 40 - .adjusted(Ok(Err(anyhow!("no commit found in this repo")))); 40 + .adjusted(Ok(Err(anyhow!("No commit found in this repo")))); 41 41 }; 42 42 43 43 let link = { 44 44 if let Some(pat) = &config.repo_url_template { 45 + debug!("using explicitly set repo_url_template"); 45 46 Permalink { 46 - template: pat 47 - .parse() 48 - .context("failed to parse `repo-url-template` as a valid url")?, 47 + template: (pat.parse()) 48 + .context("Failed to parse `repo-url-template` as a valid URL")?, 49 49 reference, 50 50 } 51 51 } else { 52 - let repo = match find_git_remote(&repo, book)? { 52 + let repo = match find_git_remote(&repo, book) 53 + .context("Error while finding a git remote URL")? 54 + { 53 55 Ok(repo) => repo, 54 56 Err(err) => { 55 - return err 56 - .context("help: or use `repo-url-template` option") 57 - .context("help: set `output.html.git-repository-url` to a GitHub url") 58 - .context("failed to determine GitHub url to use for permalinks") 57 + return anyhow!("help: or use `repo-url-template` option") 58 + .context("help: set `output.html.git-repository-url` to a GitHub URL") 59 + .context(err) 60 + .context("Failed to determine the remote URL prefix for permalinks") 59 61 .pipe(Err) 60 62 .pipe(Ok) 61 63 .pipe(|result| config.fail_on_warnings.adjusted(result)); 62 64 } 63 65 }; 64 - let (owner, repo) = remote_as_github(repo.as_ref()) 65 - .with_context(|| match repo { 66 - RepoSource::Config(..) => "in `output.html.git-repository-url`", 67 - RepoSource::Remote(..) => "from git remote \"origin\"", 68 - }) 69 - .context("help: use `repo-url-template` option for a custom remote") 70 - .context("failed to parse git remote url")?; 66 + let (owner, repo) = match remote_as_github(repo.as_ref()) { 67 + Ok(result) => result, 68 + Err(err) => { 69 + return anyhow! {"help: use the `repo-url-template` option \ 70 + to define a custom URL scheme"} 71 + .context(err) 72 + .context(match repo { 73 + RepoSource::Config(..) => "In `output.html.git-repository-url`:", 74 + RepoSource::Remote(..) => "In git remote \"origin\":", 75 + }) 76 + .context("Failed to find a git remote URL") 77 + .pipe(Err); 78 + } 79 + }; 71 80 Permalink::github(&owner, &repo, &reference) 72 81 } 73 82 }; ··· 75 84 Ok(Ok(Self { root, repo, link })) 76 85 } 77 86 87 + #[instrument(level="debug", skip_all, fields(file = format!("{file}"), root = format!("{}", self.root)))] 78 88 pub fn try_file(&self, file: &Url) -> Result<TryFile, PathStatus> { 79 89 let Some(path) = self.root.make_relative(file) else { 90 + debug!("no relative path from root"); 80 91 return Err(PathStatus::Unreachable); 81 92 }; 82 93 83 94 if path.starts_with("../") { 95 + debug!("path outside repo"); 84 96 return Err(PathStatus::NotInRepo); 85 97 } 86 98 ··· 90 102 .symlink_metadata() 91 103 { 92 104 if !self.repo.is_path_ignored(&path).unwrap_or(false) { 93 - Ok(TryFile { path, metadata }) 105 + Ok(TryFile { path, metadata }).inspect(emit_trace!()) 94 106 } else { 107 + debug!("path ignored"); 95 108 Err(PathStatus::Ignored) 96 109 } 97 110 } else { 111 + debug!("path inaccessible"); 98 112 Err(PathStatus::Unreachable) 99 113 } 100 114 } 101 115 } 102 116 117 + #[derive(Debug)] 103 118 pub struct TryFile { 104 119 pub path: String, 105 120 pub metadata: std::fs::Metadata, ··· 107 122 108 123 pub trait PermalinkFormat { 109 124 /// Try to convert this path to a permalink 110 - fn to_link(&self, path: &str, hint: ContentTypeHint) -> Result<Url>; 125 + fn to_link(&self, path: &str, hint: ContentHint) -> Result<Url>; 111 126 /// Try to extract a path (relative to repo root) from this link 112 - fn to_path(&self, link: &Url) -> Option<(String, ContentTypeHint)>; 127 + fn to_path(&self, link: &Url) -> Option<(String, ContentHint)>; 113 128 } 114 129 130 + #[derive(Debug)] 115 131 pub struct Permalink { 116 132 pub template: Url, 117 133 pub reference: String, ··· 144 160 } 145 161 146 162 impl PermalinkFormat for Permalink { 147 - fn to_link(&self, path: &str, hint: ContentTypeHint) -> Result<Url> { 163 + fn to_link(&self, path: &str, hint: ContentHint) -> Result<Url> { 148 164 let path = self 149 165 .template 150 166 .path() ··· 152 168 .map(|segment| match segment { 153 169 encoded_param!("ref") => &self.reference, 154 170 encoded_param!("tree") => match hint { 155 - ContentTypeHint::Tree => "tree", 156 - ContentTypeHint::Raw => "raw", 171 + ContentHint::Tree => "tree", 172 + ContentHint::Raw => "raw", 157 173 }, 158 174 encoded_param!("path") => path, 159 175 _ => segment, ··· 187 203 } 188 204 189 205 // this is kind of messy 190 - fn to_path(&self, link: &Url) -> Option<(String, ContentTypeHint)> { 206 + #[instrument("url_to_path", level = "debug", skip_all, fields(url = format!("{link}")))] 207 + fn to_path(&self, link: &Url) -> Option<(String, ContentHint)> { 191 208 if self.template.origin() != link.origin() { 192 209 return None; 193 210 } 194 211 195 212 let mut path = false; 196 - let mut hint = ContentTypeHint::Tree; 213 + let mut hint = ContentHint::Tree; 197 214 198 215 let mut match_param = |lhs: &str, rhs: Option<&str>| -> ControlFlow<()> { 216 + trace!("match param {lhs:?} .. {rhs:?}"); 199 217 match lhs { 200 218 encoded_param!("tree") => match rhs { 201 219 Some("tree" | "blob") => { 202 - hint = ContentTypeHint::Tree; 220 + hint = ContentHint::Tree; 203 221 ControlFlow::Continue(()) 204 222 } 205 223 Some("raw") => { 206 - hint = ContentTypeHint::Raw; 224 + hint = ContentHint::Raw; 207 225 ControlFlow::Continue(()) 208 226 } 209 227 _ => ControlFlow::Break(()), ··· 231 249 } 232 250 lhs => match match_param(lhs, rhs.next()) { 233 251 ControlFlow::Continue(()) => {} 234 - ControlFlow::Break(()) => return None, 252 + ControlFlow::Break(()) => { 253 + trace!("no {{path}} found"); 254 + return None; 255 + } 235 256 }, 236 257 } 237 258 } ··· 239 260 while let Some(lhs) = lhs.next_back() { 240 261 match match_param(lhs, rhs.next_back()) { 241 262 ControlFlow::Continue(()) => {} 242 - ControlFlow::Break(()) => return None, 263 + ControlFlow::Break(()) => { 264 + trace!("insufficient {{path}}"); 265 + return None; 266 + } 243 267 } 244 268 } 245 269 ··· 252 276 let link_query = link.query_pairs().collect::<HashMap<_, _>>(); 253 277 254 278 for (k, v) in self.template.query_pairs() { 279 + trace!("match query {k:?} .. {v:?}"); 255 280 match v.as_ref() { 256 281 "{path}" => match link_query.get(&k) { 257 282 Some(v) => { ··· 265 290 }, 266 291 "{tree}" => match link_query.get(&k).map(|v| &**v) { 267 292 Some("tree" | "blob") => { 268 - hint = ContentTypeHint::Tree; 293 + hint = ContentHint::Tree; 269 294 } 270 295 Some("raw") => { 271 - hint = ContentTypeHint::Raw; 296 + hint = ContentHint::Raw; 272 297 } 273 298 _ => return None, 274 299 }, ··· 279 304 _ => {} 280 305 } 281 306 } 307 + 308 + debug!(?path, ?hint, "path matched"); 282 309 283 310 Some((path?, hint)) 284 311 } ··· 298 325 } 299 326 } 300 327 328 + #[instrument(level = "debug", skip_all)] 301 329 fn get_git_head(repo: &Repository) -> Result<Option<String>> { 302 330 let head = match repo.head() { 303 331 Ok(head) => head, 304 332 Err(err) => { 305 - debug!("{err}"); 333 + debug!("could not resolve the currently checked-out ref: {err}"); 306 334 return Ok(None); 307 335 } 308 336 }; 309 - let head = head.peel_to_commit()?; 337 + 338 + let head = head 339 + .peel_to_commit() 340 + .context("Failed to resolve the commit HEAD is at")?; 341 + 342 + debug!("HEAD is at {}", head.id()); 343 + 310 344 if let Ok(tag) = head 311 345 .as_object() 312 346 .describe( ··· 314 348 .describe_tags() 315 349 .max_candidates_tags(0), // exact match 316 350 ) 317 - .tap_err(emit_debug!()) 318 351 .and_then(|tag| tag.format(None)) 319 - .tap_err(emit_debug!()) 352 + .inspect_err(emit_debug!("no exact tag found: {}")) 320 353 { 321 - info!("using tag {tag:?}"); 354 + info!("Using tag name {tag:?} for permalinks"); 322 355 Ok(Some(tag)) 323 356 } else { 324 357 let sha = head.id().to_string(); 325 - info!("using commit {sha}"); 358 + info!("Using commit hash {sha} for permalinks"); 326 359 Ok(Some(sha)) 327 360 } 328 361 } 329 362 363 + #[instrument(level = "debug", skip_all)] 330 364 fn find_git_remote(repo: &Repository, config: &MDBookConfig) -> Result<Result<RepoSource>> { 331 - if let Some(url) = config 332 - .get::<String>("output.html.git-repository-url") 333 - .context("failed to get `output.html.git-repository-url`")? 334 - { 335 - gix_url::parse(url.as_str().into())? 365 + if let Some(url) = config.get::<String>("output.html.git-repository-url")? { 366 + debug!("found {url:?} in book.toml"); 367 + gix_url::parse(url.as_str().into()) 368 + .inspect(emit_debug!("parsed as {:?}"))? 336 369 .pipe(RepoSource::Config) 337 370 .pipe(Ok) 338 371 .pipe(Ok) 339 372 } else { 340 373 let repo = match repo 341 374 .find_remote("origin") 342 - .context("no such remote `origin`") 375 + .context("Repo does not have remote named `origin`") 343 376 { 344 377 Ok(repo) => repo, 345 378 Err(err) => return Ok(Err(err)), 346 379 }; 347 380 let repo = match repo.url() { 348 381 Some(url) => url, 349 - None => return Ok(Err(anyhow!("remote `origin` does not have a url"))), 382 + None => return Ok(Err(anyhow!("Remote `origin` does not have a URL"))), 350 383 }; 351 - gix_url::parse(repo.into())? 384 + debug!("found {repo:?} via remote `origin`"); 385 + gix_url::parse(repo.into()) 386 + .inspect(emit_debug!("parsed as {:?}"))? 352 387 .pipe(RepoSource::Remote) 353 388 .pipe(Ok) 354 389 .pipe(Ok) ··· 357 392 358 393 fn remote_as_github(url: &gix_url::Url) -> Result<(String, String)> { 359 394 let Some(host) = url.host() else { 360 - bail!("remote url does not have a host") 395 + bail!("Remote URL does not have a host") 361 396 }; 362 397 363 398 if host != "github.com" && !host.ends_with(".github.com") { 364 - bail!("unsupported remote {host:?}, only `github.com` is supported") 399 + bail!("Unsupported remote {host:?}, only `github.com` is supported") 365 400 } 366 401 367 402 let path = url.path.to_string(); ··· 370 405 371 406 let owner = iter 372 407 .next() 373 - .with_context(|| format!("malformed path {path:?}, expected `/<owner>/<repo>`"))?; 408 + .with_context(|| format!("Malformed path {path:?}, expected `/<owner>/<repo>`"))?; 374 409 375 410 let repo = iter 376 411 .next() 377 - .with_context(|| format!("malformed path {path:?}, expected `/<owner>/<repo>`"))?; 412 + .with_context(|| format!("Malformed path {path:?}, expected `/<owner>/<repo>`"))?; 378 413 379 414 let repo = repo.strip_suffix(".git").unwrap_or(repo); 380 415 ··· 382 417 } 383 418 384 419 #[cfg(test)] 420 + #[allow(clippy::unwrap_used)] 385 421 mod tests { 386 422 use anyhow::Result; 387 423 use git2::Repository; 388 424 use mdbook_preprocessor::config::Config as MDBookConfig; 389 425 390 - use crate::link::ContentTypeHint; 426 + use crate::link::ContentHint; 391 427 392 428 use super::{Permalink, PermalinkFormat, find_git_remote, remote_as_github}; 393 429 ··· 432 468 } 433 469 434 470 #[test] 435 - #[should_panic(expected = "unsupported remote")] 471 + #[should_panic(expected = "Unsupported remote")] 436 472 fn test_non_github() { 437 473 let config = r#" 438 474 [output.html] ··· 449 485 fn test_path_to_link() -> Result<()> { 450 486 let scheme = Permalink::github("lorem", "ipsum", "main"); 451 487 452 - let link = scheme.to_link(".editorconfig", ContentTypeHint::Tree)?; 488 + let link = scheme.to_link(".editorconfig", ContentHint::Tree)?; 453 489 454 490 assert_eq!( 455 491 link.as_str(), ··· 466 502 reference: "master".into(), 467 503 }; 468 504 469 - let link = scheme.to_link(".editorconfig", ContentTypeHint::Tree)?; 505 + let link = scheme.to_link(".editorconfig", ContentHint::Tree)?; 470 506 471 507 assert_eq!( 472 508 link.as_str(), ··· 485 521 .unwrap(); 486 522 487 523 assert_eq!(path, "path/to/file"); 488 - assert_eq!(hint, ContentTypeHint::Raw); 524 + assert_eq!(hint, ContentHint::Raw); 489 525 490 526 Ok(()) 491 527 } ··· 520 556 scheme.to_path(&"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/raw/.editorconfig?h=HEAD".parse()?).unwrap(); 521 557 522 558 assert_eq!(path, ".editorconfig"); 523 - assert_eq!(hint, ContentTypeHint::Raw); 559 + assert_eq!(hint, ContentHint::Raw); 524 560 525 561 Ok(()) 526 562 }
+6 -4
crates/mdbook-permalinks/tests/env.rs
··· 96 96 .current_dir(&root) 97 97 .assert() 98 98 .success() 99 - .stderr(predicate::str::contains("no commit found")); 99 + .stderr(predicate::str::contains("No commit found")); 100 100 101 101 info!("when: repo has no origin"); 102 102 Command::new("git") ··· 119 119 .current_dir(&root) 120 120 .assert() 121 121 .success() 122 - .stderr(predicate::str::contains("failed to determine GitHub url")); 122 + .stderr(predicate::str::contains( 123 + "Failed to determine the remote URL", 124 + )); 123 125 124 126 info!("when: repo has origin"); 125 127 Command::new("git") ··· 142 144 .assert() 143 145 .success() 144 146 .stderr(predicate::str::contains("[WARN]").not()) 145 - .stderr(predicate::str::contains("using commit")); 147 + .stderr(predicate::str::contains("Using commit hash")); 146 148 147 149 info!("when: HEAD is tagged"); 148 150 Command::new("git") ··· 160 162 .assert() 161 163 .success() 162 164 .stderr(predicate::str::contains("[WARN]").not()) 163 - .stderr(predicate::str::contains("using tag \"v0.1.0\"")); 165 + .stderr(predicate::str::contains("Using tag name \"v0.1.0\"")); 164 166 165 167 Ok(()) 166 168 }
+1
crates/mdbook-rustdoc-links/Cargo.toml
··· 22 22 cargo_toml = { version = "0.21.0" } 23 23 clap = { workspace = true } 24 24 dirs = { version = "6.0.0" } 25 + futures-util = { workspace = true } 25 26 lsp-types = { version = "0.95.0" } 26 27 mdbook-markdown = { workspace = true } 27 28 mdbook-preprocessor = { workspace = true }
+28 -26
crates/mdbook-rustdoc-links/src/cache.rs
··· 1 1 use std::{ 2 2 borrow::Cow, 3 3 collections::{BTreeSet, HashMap}, 4 - hash::Hash, 5 4 iter, 6 5 }; 7 6 ··· 9 8 use lsp_types::Url; 10 9 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 11 10 use sha2::{Digest, Sha256}; 12 - use tap::{Pipe, Tap, TapFallible}; 11 + use tap::{Pipe, Tap}; 13 12 use tokio::task::JoinSet; 14 - use tracing::debug; 13 + use tracing::{debug, instrument}; 15 14 16 - use mdbookkit::emit_debug; 15 + use mdbookkit::url::{ExpectUrl, UrlToPath}; 17 16 18 - use crate::{env::Environment, link::ItemLinks, page::Pages, resolver::Resolver, url::UrlToPath}; 17 + use crate::{ 18 + env::Environment, 19 + link::{ItemLinks, LinkState}, 20 + page::{PageKey, Pages}, 21 + resolver::Resolver, 22 + }; 19 23 20 - #[allow(async_fn_in_trait)] 21 24 pub trait Cache: DeserializeOwned + Serialize { 22 25 type Validated: Resolver; 23 26 ··· 25 28 26 29 async fn build<K>(env: &Environment, content: &Pages<'_, K>) -> Result<Self> 27 30 where 28 - K: Eq + Hash; 31 + K: PageKey; 29 32 33 + #[instrument("cache_load", skip_all)] 30 34 async fn load(env: &Environment) -> Result<Self::Validated> { 31 - env.load_temp::<Self, _>("cache.json") 32 - .tap_err(emit_debug!())? 33 - .reuse(env) 34 - .await 35 - .tap_err(emit_debug!()) 35 + env.load_temp::<Self, _>("cache.json")?.reuse(env).await 36 36 } 37 37 38 + #[instrument("cache_save", skip_all)] 38 39 async fn save<K>(env: &Environment, content: &Pages<'_, K>) -> Result<()> 39 40 where 40 - K: Eq + Hash, 41 + K: PageKey, 41 42 { 42 43 let this = Self::build(env, content).await?; 43 44 env.save_temp::<Self, _>("cache.json", &this) 44 - .tap_err(emit_debug!()) 45 45 } 46 46 } 47 47 ··· 62 62 63 63 async fn build<K>(env: &Environment, content: &Pages<'_, K>) -> Result<Self> 64 64 where 65 - K: Eq + Hash, 65 + K: PageKey, 66 66 { 67 67 Ok(Self::V1(FileCacheV1::build(env, content).await?)) 68 68 } ··· 87 87 let hash = Self::hash(env, deps).await; 88 88 89 89 if hash != self.hash { 90 - bail!("checksum mismatch, expected {}, actual {hash}", self.hash) 90 + bail!("Checksum mismatch, expected {}, actual {hash}", self.hash) 91 91 } 92 92 93 93 Ok(self.urls.into_iter().collect()) ··· 95 95 96 96 async fn build<K>(env: &Environment, content: &Pages<'_, K>) -> Result<Self> 97 97 where 98 - K: Eq + Hash, 98 + K: PageKey, 99 99 { 100 100 let urls = content 101 - .links() 102 101 .iter() 103 - .map(|(k, v)| (k.to_string(), v.clone())) 102 + .deduped(|link| match link.state() { 103 + LinkState::Resolved(links) => Some(links), 104 + _ => None, 105 + }) 106 + .into_iter() 107 + .filter_map(|(k, v)| Some((k.to_string(), v?.clone()))) 104 108 .collect::<Vec<_>>(); 105 109 106 110 let deps = urls ··· 115 119 } 116 120 117 121 impl FileCacheV1 { 122 + #[instrument(level = "debug", skip_all, ret)] 118 123 async fn hash<'a, D>(env: &'a Environment, deps: D) -> String 119 124 where 120 125 D: Iterator<Item = Cow<'a, Url>>, 121 126 { 122 127 [ 123 - Cow::Owned(env.source_dir.join("Cargo.toml").unwrap()), 124 - Cow::Owned(env.crate_dir.join("Cargo.toml").unwrap()), 128 + Cow::Owned(env.source_dir.join("Cargo.toml").expect_url()), 129 + Cow::Owned(env.crate_dir.join("Cargo.toml").expect_url()), 125 130 Cow::Borrowed(&env.entrypoint), 126 131 ] 127 132 .into_iter() ··· 143 148 .await 144 149 .into_iter() 145 150 .filter_map(|result| { 146 - // should be tolerable to skip files that we somehow can't read? 147 - result 148 - .context("failed to read cache dependency") 149 - .tap_err(emit_debug!()) 150 - .ok() 151 + // should be acceptable to skip files that we somehow can't read? 152 + result.context("Failed to read cache dependency").ok() 151 153 }) 152 154 .collect::<Vec<_>>() 153 155 .tap_mut(|deps| deps.sort_by(|(k1, _), (k2, _)| k1.cmp(k2))) // order affects hashing
+107 -67
crates/mdbook-rustdoc-links/src/client.rs
··· 9 9 10 10 use anyhow::{Context, Result, anyhow, bail}; 11 11 use async_lsp::{LanguageServer, MainLoop, ServerSocket, router::Router}; 12 + use futures_util::TryFutureExt; 12 13 use lsp_types::{ 13 14 ClientCapabilities, ClientInfo, DidCloseTextDocumentParams, DidOpenTextDocumentParams, 14 15 GeneralClientCapabilities, GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, ··· 21 22 }; 22 23 use serde::{Deserialize, Serialize}; 23 24 use serde_json::json; 24 - use tap::TapFallible; 25 25 use tokio::{ 26 26 io::{AsyncBufReadExt, BufReader}, 27 - sync::{OnceCell, OwnedSemaphorePermit, Semaphore, mpsc}, 27 + sync::{OnceCell, OwnedSemaphorePermit, Semaphore, mpsc, oneshot}, 28 28 task::JoinHandle, 29 29 }; 30 30 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; 31 31 use tower::ServiceBuilder; 32 - use tracing::{Level, debug, trace}; 32 + use tracing::{Instrument, Level, debug, debug_span, instrument, trace, warn, warn_span}; 33 33 34 - use mdbookkit::{emit_debug, emit_warning, ticker, ticker_event}; 34 + use mdbookkit::{ 35 + emit_debug, 36 + error::{ExpectLock, FutureWithError}, 37 + ticker, ticker_event, 38 + url::UrlToPath, 39 + }; 35 40 36 41 use crate::{ 37 42 env::Environment, 38 43 link::ItemLinks, 39 - sync::{EventSampler, EventSampling}, 40 - url::UrlToPath, 44 + sync::{Debounce, Debouncing}, 41 45 }; 42 46 43 47 /// LSP client to talk to rust-analyzer. ··· 65 69 &self.env 66 70 } 67 71 72 + #[instrument("open_document", level = "debug", skip_all)] 68 73 pub async fn open(&self, uri: Url, text: String) -> Result<OpenDocument> { 69 - let server = self 70 - .server 74 + trace!(%uri); 75 + 76 + let server = (self.server) 71 77 .get_or_try_init(|| Server::spawn(&self.env)) 72 78 .await? 73 79 .clone(); 74 80 75 81 let opened = self.docs.open(server.server.clone(), uri, text).await?; 76 82 77 - server 78 - .stabilizer 79 - .wait() 83 + (server.debounce.wait()) 80 84 .await 81 - .with_context(|| format!("using rust-analyzer version {}", ra_version(&server.info))) 82 - .context("timed out waiting for rust-analyzer to finish indexing")?; 85 + .context("Timed out waiting for rust-analyzer to finish indexing")?; 83 86 84 87 Ok(opened) 85 88 } 86 89 87 - /// Shutdown the server if it was spawned. 90 + /// Shutdown the server if it has been spawned. 88 91 /// 89 92 /// Returns the [`Environment`] struct for further use. 90 - pub async fn stop(self) -> Environment { 91 - if let Some(server) = self.server.into_inner() { 93 + pub async fn stop(mut self) -> Environment { 94 + if let Some(server) = self.server.take() { 92 95 server 93 96 .dispose() 97 + .context("Failed to properly shutdown LSP server") 98 + .inspect_err(emit_debug!()) 94 99 .await 95 - .context("failed to properly stop rust-analyzer") 96 - .tap_err(emit_warning!()) 97 100 .ok(); 98 101 } 99 - self.env 102 + self.env.clone() 103 + } 104 + } 105 + 106 + impl Drop for Client { 107 + fn drop(&mut self) { 108 + // shutdown the server in a new task in case main thread bails 109 + // this avoids async-lsp's background thread panicking due to its 110 + // sender channel closing, resulting in a "Sender is alive" assertion 111 + if let Some(server) = self.server.take() { 112 + tokio::spawn(server.dispose()); 113 + } 100 114 } 101 115 } 102 116 103 117 #[derive(Debug, Clone)] 104 118 struct Server { 105 119 server: ServerSocket, 106 - stabilizer: EventSampler<()>, 120 + debounce: Debounce<()>, 107 121 background: Arc<JoinHandle<()>>, 108 - info: Option<ServerInfo>, 109 122 } 110 123 111 124 impl Server { 125 + #[instrument("spawn_lsp", level = "debug", skip_all)] 112 126 async fn spawn(env: &Environment) -> Result<Self> { 113 127 struct State { 114 128 sender: mpsc::Sender<Poll<()>>, ··· 160 174 /// the first [`WorkDoneProgress::End`] event; see [`EventSampler`]. 161 175 /// 162 176 /// [workDoneProgress]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workDoneProgress 177 + #[instrument(level = "trace", skip(state))] 163 178 fn probe_progress(state: &mut State, progress: ProgressParams) { 164 179 match indexing_progress(&progress) { 165 180 Some(WorkDoneProgress::Begin(begin)) => { ··· 183 198 } 184 199 185 200 let Some(indexed) = state.percent_indexed.as_mut() else { 186 - // progress was invalidated 201 + trace!("progress was considered spurious"); 187 202 return; 188 203 }; 189 204 ··· 194 209 // because RA isn't actually indexing everything at this point 195 210 msg.starts_with("0/1 (") 196 211 } else { 197 - // also ignore indexing runs that went from 0 to a 100 212 + // also ignore indexing runs that went from 0 to 100 198 213 *indexed == 0 && update == 100 199 214 }; 200 215 216 + trace!(update, spurious, "WorkDoneProgress::Report"); 217 + 201 218 if spurious { 202 219 debug!("ignoring spurious rust-analyzer progress"); 203 220 state.percent_indexed = None; ··· 211 228 212 229 Some(WorkDoneProgress::End(_)) => { 213 230 let Some(indexed) = state.percent_indexed else { 214 - // progress was invalidated 231 + trace!("progress was considered spurious"); 215 232 return; 216 233 }; 217 234 235 + trace!(indexed, "WorkDoneProgress::End"); 236 + 218 237 if indexed < 99 { 219 238 return; 220 239 } ··· 226 245 } 227 246 228 247 None => { 229 - trace!("{progress:#?}") 248 + trace!("ignoring unsupported workDoneProgress") 230 249 } 231 250 } 232 251 } 233 252 234 253 let (sender, receiver) = mpsc::channel(16); 235 254 236 - let stabilizer = EventSampling { 237 - buffer: Duration::from_millis(500), 255 + let debounce = Debouncing { 256 + debounce: Duration::from_millis(500), 238 257 timeout: env.config.rust_analyzer_timeout(), 239 258 receiver, 240 259 } ··· 256 275 257 276 router 258 277 .notification::<Progress>(|state, progress| { 259 - trace!("{progress:#?}"); 260 278 probe_progress(state, progress); 261 279 ControlFlow::Continue(()) 262 280 }) 263 281 .notification::<PublishDiagnostics>(|_, diagnostics| { 264 - trace!("{diagnostics:#?}"); 282 + trace!("{diagnostics:?}"); 265 283 ControlFlow::Continue(()) 266 284 }) 267 285 .notification::<ShowMessage>(|_, ShowMessageParams { typ, message }| { ··· 280 298 let mut proc = env 281 299 .which() 282 300 .command()? 283 - .current_dir(env.crate_dir.to_path()?) 301 + .current_dir(env.crate_dir.expect_path()) 284 302 .stdin(Stdio::piped()) 285 303 .stdout(Stdio::piped()) 286 304 .stderr(Stdio::piped()) 287 305 .spawn() 288 - .context("failed to spawn rust-analyzer")?; 306 + .context("Failed to spawn rust-analyzer")?; 289 307 290 308 let background = { 291 - let stdin = proc.stdin.take().unwrap(); 292 - let stdout = proc.stdout.take().unwrap(); 293 - let stderr = proc.stderr.take().unwrap(); 309 + let stdin = proc.stdin.take().expect("should have stdio"); 310 + let stdout = proc.stdout.take().expect("should have stdio"); 311 + let stderr = proc.stderr.take().expect("should have stdio"); 294 312 295 - tokio::spawn(async move { 296 - let mut stderr = BufReader::new(stderr).lines(); 297 - while let Some(line) = stderr.next_line().await.ok().flatten() { 298 - debug!("{line}"); 313 + let (tx, rx) = oneshot::channel(); 314 + 315 + tokio::spawn( 316 + async move { 317 + let mut stderr = BufReader::new(stderr).lines(); 318 + let mut buffer = vec![]; 319 + while let Some(line) = stderr.next_line().await.ok().flatten() { 320 + debug!("{line}"); 321 + buffer.push(line); 322 + } 323 + tx.send(buffer).ok(); 299 324 } 300 - }); 325 + .instrument(debug_span!("lsp_stderr")), 326 + ); 301 327 302 - let background = tokio::spawn(async move { 303 - background 304 - .run_buffered(stdout.compat(), stdin.compat_write()) 305 - .await 306 - .context("LSP client stopped unexpectedly") 307 - .tap_err(emit_debug!()) 308 - .ok(); 309 - }); 328 + let background = tokio::spawn( 329 + async move { 330 + if let Err(e) = background 331 + .run_buffered(stdout.compat(), stdin.compat_write()) 332 + .await 333 + { 334 + warn!("LSP client stopped unexpectedly: {e}"); 335 + if let Some(stderr) = tokio::time::timeout(Duration::from_millis(100), rx) 336 + .await 337 + .into_iter() 338 + .flatten() 339 + .next() 340 + { 341 + warn!("Server process stderr:"); 342 + for line in stderr { 343 + warn!(" {line}"); 344 + } 345 + } 346 + } 347 + } 348 + .instrument(warn_span!("lsp_thread")), 349 + ); 310 350 311 351 Arc::new(background) 312 352 }; ··· 362 402 363 403 ..Default::default() 364 404 }) 405 + .context("Failed to initialize rust-analyzer") 365 406 .await?; 407 + 408 + debug!("using rust-analyzer {info:?}"); 366 409 367 410 if capabilities.position_encoding != Some(PositionEncodingKind::UTF8) { 368 - let version = ra_version(&info); 369 - let error = anyhow!("using rust-analyzer version {version}") 370 - .context("this rust-analyzer does not support utf-8 positions"); 411 + let error = anyhow!("Found rust-analyzer version {}", ra_version(&info)) 412 + .context("Server does not support utf-8 positions") 413 + .context("Unsupported rust-analyzer version"); 371 414 bail!(error) 372 415 } 373 416 374 - server.initialized(InitializedParams {})?; 417 + server.initialized(InitializedParams {}).ok(); 375 418 376 419 Ok(Self { 377 420 server, 378 - stabilizer, 421 + debounce, 379 422 background, 380 - info, 381 423 }) 382 424 } 383 425 ··· 387 429 background, 388 430 .. 389 431 } = self; 390 - let background = Arc::into_inner(background) 391 - .expect("should not dispose while multiple server sockets are still alive"); 392 432 server.shutdown(()).await?; 393 433 server.exit(())?; 394 434 server.emit(StopEvent)?; 395 - background.await?; 435 + if let Some(background) = Arc::into_inner(background) { 436 + background.await? 437 + } 396 438 Ok(()) 397 439 } 398 440 } ··· 414 456 text: String, 415 457 ) -> Result<OpenDocument> { 416 458 let (sema, version) = { 417 - let mut lock = self.opened.write().unwrap(); 459 + let mut lock = self.opened.write().expect_lock(); 418 460 let (sema, version) = lock 419 461 .entry(uri.clone()) 420 462 .or_insert_with(|| (Arc::new(Semaphore::new(1)), 0)); ··· 433 475 }, 434 476 })?; 435 477 436 - debug!("textDocument/didOpen {}", uri); 437 - 438 478 Ok(OpenDocument { 439 479 uri, 440 480 server, ··· 454 494 } 455 495 456 496 impl OpenDocument { 497 + #[instrument(level = "debug", skip(self))] 457 498 pub async fn resolve(&self, position: Position) -> Result<ItemLinks> { 458 499 let defs = self 459 500 .server ··· 463 504 work_done_progress_params: Default::default(), 464 505 partial_result_params: Default::default(), 465 506 }) 507 + .context("Failed to request source definition") 508 + .inspect_err(emit_debug!()) 466 509 .await 467 - .context("failed to request source definition") 468 - .tap_err(emit_warning!()) 469 510 .unwrap_or_default() 470 511 .map(|defs| match defs { 471 512 GotoDefinitionResponse::Scalar(loc) => vec![loc.uri], ··· 481 522 let ExternalDocLinks { web, local } = self 482 523 .server 483 524 .request::<ExternalDocs>(document_position(self.uri.clone(), position)) 525 + .context("Failed to request external docs") 526 + .inspect_err(emit_debug!()) 484 527 .await 485 - .context("failed to request external docs") 486 - .tap_err(emit_warning!()) 487 528 .unwrap_or_default() 488 - .context("server returned no result for external docs")?; 529 + .context("Server returned no result for external docs")?; 489 530 490 531 ItemLinks::new(web, local, defs) 491 532 } ··· 499 540 uri: self.uri.clone(), 500 541 }, 501 542 }) 502 - .tap_ok(|_| debug!("textDocument/didClose {}", self.uri)) 503 - .context("error sending textDocument/didClose") 504 - .tap_err(emit_debug!()) 543 + .context("Error sending textDocument/didClose") 544 + .inspect_err(emit_debug!()) 505 545 .ok(); 506 546 } 507 547 }
+70 -34
crates/mdbook-rustdoc-links/src/env.rs
··· 5 5 time::Duration, 6 6 }; 7 7 8 - use anyhow::{Context, Result, anyhow, bail}; 8 + use anyhow::{Context, Result, anyhow}; 9 9 use cargo_toml::{Manifest, Product}; 10 10 use lsp_types::Url; 11 11 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 12 12 use shlex::Shlex; 13 - use tap::Pipe; 14 13 use tokio::process::Command; 15 14 use tracing::debug; 16 15 17 - use mdbookkit::{error::OnWarning, markdown::default_markdown_options}; 16 + use mdbookkit::{ 17 + error::{IntoAnyhow, OnWarning}, 18 + markdown::default_markdown_options, 19 + url::{ExpectUrl, UrlFromPath}, 20 + }; 18 21 19 22 use crate::markdown; 20 23 ··· 119 122 } 120 123 } 121 124 122 - #[derive(Debug, Clone)] 125 + #[derive(Clone)] 123 126 pub struct Environment { 124 127 pub temp_dir: TempDir, 125 128 pub crate_dir: Url, ··· 135 138 .clone() 136 139 .map(Ok) 137 140 .unwrap_or_else(std::env::current_dir) 138 - .context("failed to get the current working directory")? 141 + .context("Failed to get the current working directory")? 139 142 .canonicalize() 140 - .context("failed to resolve `manifest-dir` to a path")?; 143 + .context("Failed to resolve `manifest-dir` to a path")?; 141 144 142 145 let (crate_dir, entrypoint) = { 143 146 let manifest_path = LocateProject::package(&cwd) 144 - .context("preprocessor requires a Cargo project to run rust-analyzer") 145 - .context("failed to determine the current Cargo project")? 147 + .context("Preprocessor requires a Cargo project to run rust-analyzer") 148 + .context("Failed to determine the current Cargo project")? 146 149 .root; 147 150 148 151 let manifest = Manifest::from_path(&manifest_path) 149 152 .and_then(|mut m| m.complete_from_path(&manifest_path).and(Ok(m))) 150 - .context("failed to read Cargo.toml")?; 153 + .context("Failed to read from Cargo.toml")?; 151 154 152 155 let crate_dir = manifest_path 153 156 .parent() 154 - .unwrap() 155 - .pipe(Url::from_directory_path) 156 - .unwrap(); 157 + .expect("manifest_path should have a parent") 158 + .to_directory_url(); 157 159 158 160 if let Some(Product { 159 161 path: Some(ref lib), 160 162 .. 161 163 }) = manifest.lib 162 164 { 163 - let entry = crate_dir.join(lib)?; 165 + let entry = crate_dir.join(lib).expect_url(); 164 166 Ok((crate_dir, entry)) 165 167 } else if let Some(bin) = manifest.bin.iter().find_map(|bin| bin.path.as_ref()) { 166 - let entry = crate_dir.join(bin)?; 168 + let entry = crate_dir.join(bin).expect_url(); 167 169 Ok((crate_dir, entry)) 168 170 } else { 169 171 let err = Err(anyhow!( ··· 171 173 manifest_path.display() 172 174 )); 173 175 if manifest.workspace.is_some() { 174 - err.context("help: to use in a workspace, set `manifest-dir` option to root of a member crate") 176 + err.context( 177 + "help: for usage in a workspace, set option \ 178 + `manifest-dir` to the root of a member crate", 179 + ) 175 180 } else { 176 181 err 177 182 } ··· 180 185 }?; 181 186 182 187 let source_dir = LocateProject::workspace(cwd) 183 - .context("failed to locate the current Cargo project")? 184 - .root 185 - .parent() 186 - .unwrap() 187 - .pipe(Url::from_directory_path) 188 - .unwrap(); 188 + .context("Failed to locate the current Cargo project")? 189 + .directory() 190 + .to_directory_url(); 189 191 190 192 let temp_dir = match config.cache_dir.clone() { 191 193 Some(path) => Some(TempDir::Persistent(path)), ··· 194 196 .map(Arc::new) 195 197 .map(TempDir::Transient), 196 198 } 197 - .context("failed to obtain a temporary directory")?; 199 + .context("Failed to obtain a temporary directory")?; 198 200 199 201 Ok(Self { 200 202 temp_dir, ··· 232 234 P: AsRef<Path>, 233 235 { 234 236 let path = self.temp_dir.as_ref().join(path); 235 - let text = std::fs::read_to_string(&path).context("failed to read from cache dir")?; 237 + debug!("reading temp file from {}", path.display()); 238 + let text = std::fs::read_to_string(&path)?; 236 239 Ok(serde_json::from_str(&text)?) 237 240 } 238 241 ··· 242 245 P: AsRef<Path>, 243 246 { 244 247 let path = self.temp_dir.as_ref().join(path); 245 - let text = serde_json::to_string(&temp).context("failed to serialize cache data")?; 246 - std::fs::create_dir_all(path.parent().unwrap()).context("failed to create cache dir")?; 247 - std::fs::write(path, text).context("failed to write to cache dir")?; 248 + debug!("saving temp file to {}", path.display()); 249 + let text = serde_json::to_string(&temp).context("Failed to serialize cache data")?; 250 + std::fs::create_dir_all(path.parent().expect("temp dir should have a parent")) 251 + .context("Failed to create cache dir")?; 252 + std::fs::write(path, text).context("Failed to write to cache dir")?; 248 253 Ok(()) 249 254 } 250 255 } ··· 274 279 } 275 280 276 281 impl LocateProject { 282 + fn directory(&self) -> &Path { 283 + self.root 284 + .parent() 285 + .expect("path to Cargo.toml should have a parent") 286 + } 287 + 277 288 fn package<P: AsRef<Path>>(cwd: P) -> Result<Self> { 278 289 std::process::Command::new("cargo") 279 290 .arg("locate-project") 280 291 .arg("--message-format=json") 281 292 .current_dir(cwd) 282 293 .output() 283 - .context("failed to run `cargo locate-project`, is cargo installed?") 294 + .context("Failed to run `cargo locate-project`, is cargo installed?") 284 295 .and_then(Self::parse) 285 296 } 286 297 ··· 291 302 .arg("--workspace") 292 303 .current_dir(cwd) 293 304 .output() 294 - .context("failed to run `cargo locate-project`, is cargo installed?") 305 + .context("Failed to run `cargo locate-project`, is cargo installed?") 295 306 .and_then(Self::parse) 296 307 } 297 308 298 309 fn parse(output: std::process::Output) -> Result<Self> { 299 - if output.status.success() { 300 - String::from_utf8(output.stdout)? 301 - .pipe(|outout| serde_json::from_str::<Self>(&outout))? 302 - .pipe(Ok) 310 + let std::process::Output { 311 + status, 312 + stderr, 313 + stdout, 314 + } = output; 315 + if status.success() { 316 + (String::from_utf8(stdout).anyhow()) 317 + .and_then(|output| serde_json::from_str(&output).anyhow()) 318 + .context("Could not parse `cargo locate-project` output") 303 319 } else { 304 - bail!(String::from_utf8_lossy(&output.stderr).into_owned()); 320 + Err(anyhow!(String::from_utf8_lossy(&stderr).into_owned())) 321 + .context("`cargo locate-project` did not run successfully") 305 322 } 306 323 } 307 324 } ··· 319 336 let mut words = Shlex::new(cmd); 320 337 let executable = words 321 338 .next() 322 - .context("unexpected empty string for option `rust-analyzer`")?; 339 + .context("Unexpected empty string for option `rust-analyzer`")?; 323 340 let mut cmd = Command::new(executable); 324 341 cmd.args(words); 325 342 Ok(cmd) ··· 360 377 } 361 378 }) 362 379 } 380 + 381 + impl std::fmt::Debug for Environment { 382 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 383 + let Self { 384 + temp_dir, 385 + crate_dir, 386 + source_dir, 387 + entrypoint, 388 + config, 389 + } = self; 390 + f.debug_struct("Environment") 391 + .field("crate_dir", &format_args!("\"{crate_dir}\"")) 392 + .field("source_dir", &format_args!("\"{source_dir}\"")) 393 + .field("entrypoint", &format_args!("\"{entrypoint}\"")) 394 + .field("config", &config) 395 + .field("temp_dir", &temp_dir) 396 + .finish() 397 + } 398 + }
+14 -5
crates/mdbook-rustdoc-links/src/item.rs
··· 1 - use anyhow::Result; 1 + use anyhow::{Context, Result}; 2 2 use syn::{ 3 - parenthesized, 3 + PathArguments, QSelf, Token, TypePath, parenthesized, 4 4 parse::{End, Parse, ParseStream, Parser}, 5 5 spanned::Spanned, 6 6 token::Paren, 7 - PathArguments, QSelf, Token, TypePath, 8 7 }; 8 + use tracing::trace; 9 9 10 10 /// Texts that look like Rust items. 11 11 #[derive(Debug)] ··· 25 25 pub fn parse(path: &str) -> Result<Self> { 26 26 let path = match path.split_once('@') { 27 27 None => path, 28 - Some((_, path)) => path, 28 + Some((prefix, path)) => { 29 + trace!("ignoring prefix {prefix:?}"); 30 + path 31 + } 29 32 }; 30 33 31 - let item = ItemName::parse.parse_str(path)?; 34 + let item = ItemName::parse 35 + .parse_str(path) 36 + .context("could not parse as an item name")?; 32 37 33 38 let (name, column) = { 34 39 let mut name = String::new(); 35 40 let mut column = 0; 36 41 37 42 let gt = if let Some(QSelf { ty, position, .. }) = item.path.qself { 43 + trace!("fully qualified syntax"); 38 44 name.push('<'); 39 45 name.push_str(&path[ty.span().byte_range()]); 40 46 name.push_str(" as "); ··· 55 61 56 62 PathArguments::AngleBracketed(args) => { 57 63 if args.colon2_token.is_none() { 64 + trace!("turbofish"); 58 65 // make it a turbofish 59 66 name.push_str("::"); 60 67 } ··· 77 84 78 85 (name, column) 79 86 }; 87 + 88 + trace!(?name, kind = ?item.kind); 80 89 81 90 let (stmt, cursor) = match item.kind { 82 91 None => {
+11 -23
crates/mdbook-rustdoc-links/src/link.rs
··· 4 4 use lsp_types::Url; 5 5 use mdbook_markdown::pulldown_cmark::{CowStr, Event, LinkType, Tag, TagEnd}; 6 6 use serde::{Deserialize, Serialize}; 7 - use tap::{Pipe, Tap, TapFallible}; 7 + use tap::{Pipe, Tap}; 8 8 9 9 use mdbookkit::emit_trace; 10 10 ··· 24 24 #[derive(Debug)] 25 25 pub enum LinkState { 26 26 Unparsed, 27 - Parsed(Item), 27 + Pending(Item), 28 28 Resolved(ItemLinks), 29 29 } 30 30 ··· 36 36 }; 37 37 38 38 let state = Item::parse(path) 39 - .tap_err(emit_trace!()) 39 + .inspect_err(emit_trace!()) 40 40 .ok() 41 - .map(LinkState::Parsed) 41 + .map(LinkState::Pending) 42 42 .unwrap_or(LinkState::Unparsed); 43 43 44 44 let inner = vec![]; ··· 60 60 &self.url 61 61 } 62 62 63 - pub fn state(&mut self) -> &mut LinkState { 64 - &mut self.state 65 - } 66 - 67 - pub fn inner(&mut self) -> &mut Vec<Event<'a>> { 68 - &mut self.inner 63 + pub fn state(&self) -> &LinkState { 64 + &self.state 69 65 } 70 66 71 - pub fn item(&self) -> Option<&Item> { 72 - if let LinkState::Parsed(item) = &self.state { 73 - Some(item) 74 - } else { 75 - None 76 - } 67 + pub fn state_mut(&mut self) -> &mut LinkState { 68 + &mut self.state 77 69 } 78 70 79 - pub fn link(&self) -> Option<ItemLinks> { 80 - if let LinkState::Resolved(item) = &self.state { 81 - Some(item.clone()) 82 - } else { 83 - None 84 - } 71 + pub fn inner_mut(&mut self) -> &mut Vec<Event<'a>> { 72 + &mut self.inner 85 73 } 86 74 87 75 pub fn emit(&self, options: &EmitConfig) -> Option<(__emit::EmitLink<'_>, Range<usize>)> { ··· 149 137 (None, Some(file)) => Locations::File { 150 138 file: Arc::new(file), 151 139 }, 152 - (None, None) => bail!("neither web nor local link provided"), 140 + (None, None) => bail!("Neither web nor local link provided"), 153 141 }; 154 142 let defs = defs.into_iter().map(Into::into).collect(); 155 143 Ok(Self { refs, defs })
+8 -11
crates/mdbook-rustdoc-links/src/link/diagnostic.rs
··· 37 37 match self { 38 38 Self::Unresolved => Level::WARN, 39 39 Self::Debug => Level::TRACE, 40 - Self::Ok => Level::INFO, 40 + Self::Ok => Level::DEBUG, 41 41 } 42 42 } 43 - } 44 43 45 - impl fmt::Display for LinkStatus { 46 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 - let msg = match self { 48 - Self::Unresolved => "failed to resolve some links", 49 - Self::Ok => "successfully resolved all links", 44 + fn title(&self) -> impl fmt::Display { 45 + match self { 46 + Self::Unresolved => "item could not be resolved", 47 + Self::Ok => "link resolved", 50 48 Self::Debug => "debug info", 51 - }; 52 - fmt::Display::fmt(msg, f) 49 + } 53 50 } 54 51 } 55 52 ··· 57 54 pub fn diagnostic(&self) -> LinkDiagnostic { 58 55 let status = match self.state { 59 56 LinkState::Unparsed => LinkStatus::Debug, 60 - LinkState::Parsed(_) => LinkStatus::Unresolved, 57 + LinkState::Pending(_) => LinkStatus::Unresolved, 61 58 LinkState::Resolved(_) => LinkStatus::Ok, 62 59 }; 63 60 let label = match &self.state { 64 61 LinkState::Unparsed => Some(self.url.as_ref().into()), 65 - LinkState::Parsed(item) => Some(format!("failed to resolve link for {:?}", item.name)), 62 + LinkState::Pending(item) => Some(format!("could not obtain a link to {:?}", item.name)), 66 63 LinkState::Resolved(links) => Some(format!("{}", links.url())), 67 64 }; 68 65 let label = LabeledSpan::new_with_span(label, self.span.clone());
+123 -74
crates/mdbook-rustdoc-links/src/main.rs
··· 1 + #![warn(clippy::unwrap_used)] 2 + 1 3 use std::{collections::HashMap, io::Write}; 2 4 3 5 use anyhow::{ ··· 6 8 Result as Result2, 7 9 }; 8 10 use clap::{Parser, Subcommand}; 11 + use futures_util::TryFutureExt; 9 12 use mdbook_preprocessor::PreprocessorContext; 10 - use tap::{Pipe, TapFallible}; 11 - use tracing::{instrument, level_filters::LevelFilter, warn}; 13 + use tap::{Pipe, Tap}; 14 + use tracing::{Level, debug, info, info_span, warn}; 12 15 13 16 use mdbookkit::{ 14 17 book::{BookConfigHelper, BookHelper, book_from_stdin, string_from_stdin}, 15 18 diagnostics::Issue, 16 - emit_warning, 19 + emit_debug, emit_error, emit_trace, emit_warning, 20 + error::{ExitProcess, FutureWithError}, 17 21 logging::Logging, 18 22 }; 19 23 ··· 21 25 cache::{Cache, FileCache}, 22 26 client::Client, 23 27 env::{Config, Environment, RustAnalyzer}, 24 - link::diagnostic::LinkStatus, 28 + link::{LinkState, diagnostic::LinkStatus}, 25 29 page::Pages, 26 30 resolver::Resolver, 27 31 }; ··· 37 41 mod sync; 38 42 #[cfg(test)] 39 43 mod tests; 40 - mod url; 41 44 42 45 #[tokio::main] 43 - async fn main() -> Result2<()> { 46 + async fn main() { 44 47 Logging::default().init(); 48 + let _span = info_span!({ env!("CARGO_PKG_NAME") }).entered(); 45 49 match Program::parse().command { 46 50 Some(Command::Supports { .. }) => Ok(()), 47 51 Some(Command::Markdown(options)) => markdown(options).await, ··· 50 54 Some(Command::Describe) => describe(), 51 55 None => mdbook().await, 52 56 } 57 + .exit(emit_error!()) 53 58 } 54 59 55 60 #[derive(Parser, Debug, Clone)] ··· 66 71 /// Show which `rust-analyzer` is being used. 67 72 RustAnalyzer, 68 73 69 - /// Support command for mdbook. 74 + /// Support command for mdBook. 70 75 /// 71 76 /// See <https://rust-lang.github.io/mdBook/for_developers/preprocessors.html#hooking-into-mdbook> 72 77 #[clap(hide = true)] ··· 77 82 Describe, 78 83 } 79 84 80 - #[instrument("mdbook-rustdoc-links")] 81 85 async fn mdbook() -> Result2<()> { 82 - let (ctx, mut book) = book_from_stdin().context("failed to read from mdbook")?; 86 + let (ctx, mut book) = book_from_stdin().context("Failed to read from mdBook")?; 83 87 84 - let config = config(&ctx).context("failed to read preprocessor config from book.toml")?; 88 + let config = config(&ctx).context("Failed to read preprocessor config from book.toml")?; 85 89 86 90 let client = Environment::new(config) 87 - .context("failed to initialize `mdbook-rustdoc-link`")? 91 + .context("Failed to initialize preprocessor")? 92 + .tap(emit_debug!("{:#?}")) 88 93 .pipe(Client::new); 89 94 90 - let cached = FileCache::load(client.env()).await.ok(); 95 + let cached = FileCache::load(client.env()) 96 + .context("Could not load cache") 97 + .inspect_ok(emit_trace!("cache loaded: {:?}")) 98 + .inspect_err(emit_debug!()) 99 + .await 100 + .ok(); 91 101 92 102 let mut content = Pages::default(); 93 103 ··· 95 105 let stream = client.env().markdown(&ch.content).into_offset_iter(); 96 106 content 97 107 .read(path.clone(), &ch.content, stream) 98 - .with_context(|| path.display().to_string()) 99 - .context("failed to parse Markdown source:")?; 108 + .with_context(|| format!("Failed to parse {}", path.display()))?; 100 109 } 101 110 102 111 if let Some(cached) = cached { 112 + info!("Reusing cached items"); 103 113 cached.resolve(&mut content).await.ok(); 104 114 } 105 115 106 - client 107 - .resolve(&mut content) 108 - .await 109 - .context("failed to resolve some links")?; 110 - 111 - let mut result = book 112 - .iter_chapters() 113 - .filter_map(|(path, _)| { 114 - let output = content 115 - .emit(path, &client.env().emit_config()) 116 - .tap_err(emit_warning!()) 117 - .ok()?; 118 - Some((path.clone(), output.to_string())) 119 - }) 120 - .collect::<HashMap<_, _>>(); 116 + client.resolve(&mut content).await?; 121 117 122 118 let env = client.stop().await; 123 119 124 120 let status = content 125 121 .reporter() 126 - .names(|path| path.display().to_string()) 127 - .level(LevelFilter::WARN) 122 + .name_display(|path| path.display().to_string()) 128 123 .build() 129 124 .to_stderr() 130 125 .to_status(); 126 + 127 + link_report(&content); 128 + 129 + match status { 130 + LinkStatus::Unresolved => { 131 + if env.config.cache_dir.is_some() { 132 + warn! { "The `cache-dir` option is enabled, but some items could not \ 133 + be resolved, which will cause rust-analyzer to always run \ 134 + despite the cache." } 135 + } 136 + } 137 + LinkStatus::Ok | LinkStatus::Debug => { 138 + info!("Finished"); 139 + } 140 + } 141 + 142 + // bail before emitting changes 143 + env.config.fail_on_warnings.check(status.level())?; 131 144 132 145 if content.modified() { 133 - FileCache::save(&env, &content).await.ok(); 146 + FileCache::save(&env, &content) 147 + .context("Failed to save cache") 148 + .inspect_err(emit_warning!()) 149 + .await 150 + .ok(); 134 151 } 135 152 153 + let mut result = book 154 + .iter_chapters() 155 + .map(|(path, _)| { 156 + let _span = info_span!("emit", key = ?path).entered(); 157 + debug!("generating output"); 158 + let output = content 159 + .emit(path, &env.emit_config()) 160 + .context("Error generating output")?; 161 + Ok((path.clone(), output)) 162 + }) 163 + .collect::<Result2<HashMap<_, _>>>()?; 164 + 136 165 book.for_each_text_mut(|path, content| { 137 166 if let Some(output) = result.remove(path) { 138 167 *content = output; ··· 141 170 142 171 book.to_stdout(&ctx)?; 143 172 144 - env.config.fail_on_warnings.check(status.level())?; 145 - 146 - if env.config.cache_dir.is_some() && status == LinkStatus::Unresolved { 147 - warn!( 148 - "The `cache-dir` option is enabled, but some items could not \ 149 - be resolved, which will cause rust-analyzer to always run \ 150 - despite the cache." 151 - ); 152 - } 153 - 154 173 Ok(()) 155 174 } 156 175 157 176 async fn markdown(config: Config) -> Result2<()> { 158 177 let client = Environment::new(config) 159 - .context("failed to initialize")? 178 + .context("Failed to initialize")? 160 179 .pipe(Client::new); 161 180 162 - let source = string_from_stdin().context("failed to read Markdown source from stdin")?; 181 + let cached = FileCache::load(client.env()) 182 + .context("Could not load cache") 183 + .inspect_ok(emit_debug!()) 184 + .inspect_err(emit_debug!()) 185 + .await 186 + .ok(); 163 187 188 + let source = string_from_stdin().context("Failed to read Markdown source from stdin")?; 164 189 let stream = client.env().markdown(&source).into_offset_iter(); 165 190 166 - let mut content = Pages::one(&source, stream).context("failed to parse Markdown source")?; 191 + let mut content = Pages::one(&source, stream).context("Failed to parse Markdown source")?; 167 192 168 - if let Ok(cached) = FileCache::load(client.env()).await { 193 + if let Some(cached) = cached { 169 194 cached.resolve(&mut content).await.ok(); 170 195 } 171 196 172 - client 173 - .resolve(&mut content) 174 - .await 175 - .context("failed to resolve some links")?; 197 + client.resolve(&mut content).await?; 176 198 177 199 let env = client.stop().await; 178 200 179 201 let status = content 180 202 .reporter() 181 - .names(|_| "<stdin>".into()) 182 - .level(LevelFilter::WARN) 203 + .name_display(|_| "<stdin>".into()) 183 204 .build() 184 205 .to_stderr() 185 206 .to_status(); 207 + 208 + link_report(&content); 186 209 187 210 if content.modified() { 188 211 FileCache::save(&env, &content).await.ok(); 189 212 } 190 213 191 - content 192 - .get(&env.emit_config()) 193 - .map(|emit| emit.to_string()) 214 + (content.get(&env.emit_config()).map(|emit| emit.to_string())) 194 215 .and_then(|output| Ok(std::io::stdout().write_all(output.as_bytes())?))?; 195 216 196 217 env.config.fail_on_warnings.check(status.level())?; ··· 198 219 Ok(()) 199 220 } 200 221 201 - fn which() -> Result2<()> { 202 - let env = Environment::new(Default::default())?; 222 + fn link_report<K>(content: &Pages<'_, K>) { 223 + let mut iter = content.iter(); 203 224 204 - match env.which() { 205 - RustAnalyzer::Custom(cmd) => println!("using a custom command for rust-analyzer: {cmd:?}"), 206 - RustAnalyzer::VsCode(cmd) => println!( 207 - "using rust-analyzer from VS Code extension: {}", 208 - cmd.display() 209 - ), 210 - RustAnalyzer::Path => println!("using rust-analyzer on PATH (run `which rust-analyzer`)"), 211 - } 225 + let result = iter.deduped(|link| match link.state() { 226 + LinkState::Pending(..) => Some(None), 227 + LinkState::Resolved(links) => Some(Some(links.url())), 228 + LinkState::Unparsed => None, 229 + }); 212 230 213 - Ok(()) 214 - } 231 + info!("Converted {}", iter.stats().fmt_resolved()); 215 232 216 - #[cfg(feature = "_testing")] 217 - fn describe() -> Result2<()> { 218 - print!("{}", mdbookkit::docs::describe_preprocessor::<Config>()?); 219 - Ok(()) 233 + if tracing::enabled!(target: "link-report", Level::DEBUG) { 234 + for (item, link) in result 235 + .into_iter() 236 + .filter_map(|(k, v)| Some((k, v?))) 237 + .collect::<Vec<_>>() 238 + .tap_mut(|items| { 239 + items.sort_by(|(k1, u1), (k2, u2)| (k1.as_ref(), u1).cmp(&(k2.as_ref(), u2))); 240 + }) 241 + { 242 + if let Some(link) = link { 243 + info!(target: "link-report", "{item} => {link}") 244 + } else { 245 + warn!(target: "link-report", "{item} => (unresolved)") 246 + } 247 + } 248 + } 220 249 } 221 250 222 251 fn config(ctx: &PreprocessorContext) -> Result2<Config> { 223 - let mut config = ctx 224 - .config 225 - .preprocessor::<Config>(&[PREPROCESSOR_NAME, "mdbook-rustdoc-link"])?; 252 + let mut config = 253 + (ctx.config).preprocessor::<Config>(&[PREPROCESSOR_NAME, "mdbook-rustdoc-link"])?; 226 254 227 255 if let Some(path) = config.manifest_dir { 228 256 config.manifest_dir = Some(ctx.root.join(path)) ··· 235 263 } 236 264 237 265 Ok(config) 266 + } 267 + 268 + fn which() -> Result2<()> { 269 + let env = Environment::new(Default::default())?; 270 + 271 + match env.which() { 272 + RustAnalyzer::Custom(cmd) => println!("Using a custom command for rust-analyzer: {cmd:?}"), 273 + RustAnalyzer::VsCode(cmd) => println!( 274 + "Using rust-analyzer from VS Code extension: {}", 275 + cmd.display() 276 + ), 277 + RustAnalyzer::Path => println!("Using rust-analyzer on PATH"), 278 + } 279 + 280 + Ok(()) 281 + } 282 + 283 + #[cfg(feature = "_testing")] 284 + fn describe() -> Result2<()> { 285 + print!("{}", mdbookkit::docs::describe_preprocessor::<Config>()?); 286 + Ok(()) 238 287 } 239 288 240 289 static UNIQUE_ID: &str = "__ded48f4d_0c4f_4950_b17d_55fd3b2a0c86__";
+3 -3
crates/mdbook-rustdoc-links/src/markdown.rs
··· 14 14 /// [`BrokenLinkCallback`] implementation that unconditionally converts all "broken" 15 15 /// links to links to be further processed. 16 16 /// 17 - /// "Broken" links are links like `[text][link::item]` that don't have associated URLs 18 - /// that are expected for this preprocessor. 17 + /// "Broken" links are links like `[text][link::item]` that don't have associated URLs. 18 + /// Such links are expected for this preprocessor. 19 19 /// 20 20 /// Links that are "broken" that aren't actually doc links won't show up in the output, 21 21 /// because the preprocessor ignores links that cannot be parsed and is capable of ··· 57 57 .collect::<Vec<_>>(); 58 58 59 59 if inner.len() == 1 { 60 - inner.into_iter().next().unwrap() 60 + inner.into_iter().next().expect("has 1 item") 61 61 } else { 62 62 inner 63 63 .iter()
+212 -55
crates/mdbook-rustdoc-links/src/page.rs
··· 1 - use std::{ 2 - borrow::Borrow, 3 - collections::HashMap, 4 - fmt::{self, Debug}, 5 - hash::Hash, 6 - }; 1 + use std::{borrow::Borrow, collections::HashMap, fmt, hash::Hash}; 7 2 8 3 use anyhow::{Context, Result, bail}; 9 - use mdbook_markdown::pulldown_cmark::{CowStr, Event, Tag, TagEnd}; 4 + use mdbook_markdown::pulldown_cmark::{Event, Tag, TagEnd}; 10 5 use tap::Pipe; 6 + use tracing::{debug, instrument, trace, trace_span}; 11 7 12 - use mdbookkit::markdown::{PatchStream, Spanned}; 8 + use mdbookkit::{ 9 + emit_warning, 10 + markdown::{PatchStream, Spanned}, 11 + plural, 12 + }; 13 13 14 14 use crate::{ 15 15 env::EmitConfig, 16 - item::Item, 17 16 link::{ItemLinks, Link, LinkState}, 18 17 }; 19 18 ··· 31 30 links: Vec<Link<'a>>, 32 31 } 33 32 34 - impl<'a, K: Eq + Hash> Pages<'a, K> { 33 + impl<'a, K: PageKey> Pages<'a, K> { 34 + #[instrument(level = "debug", "page_read", skip_all)] 35 35 pub fn read<S>(&mut self, key: K, source: &'a str, stream: S) -> Result<()> 36 36 where 37 37 S: Iterator<Item = Spanned<Event<'a>>>, 38 38 { 39 + debug!(path = ?key, "reading file"); 39 40 self.pages.insert(key, Page::read(source, stream)?); 40 41 Ok(()) 41 42 } ··· 43 44 pub fn emit<Q>(&self, key: &Q, options: &EmitConfig) -> Result<String> 44 45 where 45 46 K: Borrow<Q>, 46 - Q: Eq + Hash + fmt::Debug + ?Sized, 47 + Q: PageKey + ?Sized, 47 48 { 48 - let page = self.pages.get(key); 49 - let page = page.with_context(|| format!("no such document {key:?}"))?; 50 - page.emit(options) 51 - } 52 - 53 - pub fn items(&self) -> HashMap<CowStr<'a>, &Item> { 54 49 self.pages 55 - .values() 56 - .flat_map(|page| page.links.iter()) 57 - .filter_map(|link| link.item().map(|item| (link.key().clone(), item))) 58 - .collect::<HashMap<_, _>>() 59 - } 60 - 61 - pub fn links(&self) -> HashMap<CowStr<'a>, ItemLinks> { 62 - self.pages 63 - .values() 64 - .flat_map(|page| page.links.iter()) 65 - .filter_map(|link| link.link().map(|item| (link.key().clone(), item))) 66 - .collect::<HashMap<_, _>>() 50 + .get(key) 51 + .with_context(|| format!("No such document {key:?}")) 52 + .inspect_err(emit_warning!()) 53 + .expect("should have document") 54 + .emit(options) 67 55 } 68 56 69 57 pub fn apply<L>(&mut self, links: &HashMap<L, ItemLinks>) ··· 73 61 for page in self.pages.values_mut() { 74 62 for link in page.links.iter_mut() { 75 63 if let Some(links) = links.get(link.key()) { 76 - *link.state() = LinkState::Resolved(links.clone()); 64 + *link.state_mut() = LinkState::Resolved(links.clone()); 77 65 self.modified = true; 78 66 } 79 67 } ··· 118 106 let mut link: Option<Link<'_>> = None; 119 107 120 108 for (event, span) in stream { 121 - if matches!(event, Event::End(TagEnd::Link)) { 122 - match link.take() { 123 - Some(link) => { 124 - if link.span() == &span { 125 - links.push(link); 126 - continue; 109 + match event { 110 + Event::End(TagEnd::Link) => match link.take() { 111 + Some(open) => { 112 + if open.span() == &span { 113 + trace!(?span, "link <<<"); 114 + links.push(open); 127 115 } else { 128 - bail!("mismatching span, expected {:?}, got {span:?}", link.span()) 116 + debug!(?span, "mismatching span, expected {:?}", open.span()); 117 + bail!("Markdown stream malformed at {span:?}"); 129 118 } 130 119 } 131 - None => bail!("unexpected `TagEnd::Link` at {span:?}"), 120 + None => { 121 + debug!(?span, "unexpected `TagEnd::Link`"); 122 + bail!("Markdown stream malformed at byte position {span:?}"); 123 + } 124 + }, 125 + Event::Start(Tag::Link { 126 + dest_url: url, 127 + title, 128 + .. 129 + }) => { 130 + if link.is_none() { 131 + trace!(?span, ?url, ?title, "link >>>"); 132 + let _span = trace_span!("read_link", ?span, ?url).entered(); 133 + link = Some(Link::new(span, url, title)); 134 + } else { 135 + debug!(?span, "unexpected `Tag::Link` in `Tag::Link`"); 136 + bail!("Markdown stream malformed at byte position {span:?}"); 137 + } 132 138 } 133 - } 134 - 135 - let Event::Start(Tag::Link { 136 - dest_url: url, 137 - title, 138 - .. 139 - }) = event 140 - else { 141 - if let Some(link) = link.as_mut() { 142 - link.inner().push(event); 139 + event => { 140 + if let Some(link) = link.as_mut() { 141 + trace!(?span, ?event, parent = ?link.span(), "link +++"); 142 + link.inner_mut().push(event); 143 + } 143 144 } 144 - continue; 145 - }; 146 - 147 - if link.is_some() { 148 - bail!("unexpected `Tag::Link` in `Tag::Link` at {span:?}") 149 145 } 150 - 151 - link = Some(Link::new(span, url, title)); 152 146 } 153 147 154 148 Ok(Self { source, links }) ··· 163 157 .pipe(Ok) 164 158 } 165 159 } 160 + 161 + pub trait PageKey: Eq + Hash + fmt::Debug {} 162 + 163 + impl<T: Eq + Hash + fmt::Debug> PageKey for T {} 164 + 165 + mod iter { 166 + use std::collections::{ 167 + HashMap, 168 + hash_map::{Entry, VacantEntry}, 169 + }; 170 + 171 + use mdbook_markdown::pulldown_cmark::CowStr; 172 + 173 + use crate::link::{Link, LinkState}; 174 + 175 + use super::{Pages, Statistics}; 176 + 177 + pub struct PagesIter<T> { 178 + iter: T, 179 + stats: Statistics, 180 + } 181 + 182 + impl<'a, K> Pages<'a, K> { 183 + pub fn iter(&'_ self) -> PagesIter<impl Iterator<Item = &'_ Link<'a>>> { 184 + PagesIter { 185 + iter: self.pages.values().flat_map(|page| page.links.iter()), 186 + stats: Default::default(), 187 + } 188 + } 189 + } 190 + 191 + impl<'p, 'a: 'p, T: Iterator<Item = &'p Link<'a>>> Iterator for PagesIter<T> { 192 + type Item = &'p Link<'a>; 193 + 194 + #[inline] 195 + fn next(&mut self) -> Option<Self::Item> { 196 + let Statistics { 197 + links_pending, 198 + links_resolved, 199 + .. 200 + } = &mut self.stats; 201 + 202 + loop { 203 + let item = self.iter.next()?; 204 + match item.state() { 205 + LinkState::Pending(..) => { 206 + *links_pending += 1; 207 + } 208 + LinkState::Resolved(..) => { 209 + *links_resolved += 1; 210 + } 211 + LinkState::Unparsed => continue, 212 + } 213 + return Some(item); 214 + } 215 + } 216 + } 217 + 218 + impl<'p, 'a: 'p, T: Iterator<Item = &'p Link<'a>>> PagesIter<T> { 219 + #[inline] 220 + pub fn deduped<F, V>(&mut self, mut f: F) -> HashMap<CowStr<'a>, Option<V>> 221 + where 222 + F: FnMut(&'p Link<'a>) -> Option<V>, 223 + { 224 + let mut map = Default::default(); 225 + while let Some(link) = self.next() { 226 + if let Some(entry) = self.record(&mut map, link) { 227 + entry.insert(f(link)); 228 + } 229 + } 230 + map 231 + } 232 + 233 + #[inline] 234 + fn record<'m, V>( 235 + &mut self, 236 + map: &'m mut HashMap<CowStr<'a>, V>, 237 + link: &'p Link<'a>, 238 + ) -> Option<VacantEntry<'m, CowStr<'a>, V>> { 239 + let Statistics { 240 + items_pending, 241 + items_resolved, 242 + .. 243 + } = &mut self.stats; 244 + 245 + let Entry::Vacant(entry) = map.entry(link.key().clone()) else { 246 + return None; 247 + }; 248 + 249 + match link.state() { 250 + LinkState::Pending(..) => { 251 + *items_pending += 1; 252 + } 253 + LinkState::Resolved(..) => { 254 + *items_resolved += 1; 255 + } 256 + LinkState::Unparsed => {} 257 + } 258 + 259 + Some(entry) 260 + } 261 + 262 + pub fn stats(&self) -> &Statistics { 263 + &self.stats 264 + } 265 + } 266 + } 267 + 268 + #[derive(Debug, Default)] 269 + pub struct Statistics { 270 + pub links_pending: usize, 271 + pub items_pending: usize, 272 + pub links_resolved: usize, 273 + pub items_resolved: usize, 274 + } 275 + 276 + impl Statistics { 277 + pub fn has_pending(&self) -> bool { 278 + self.items_pending != 0 279 + } 280 + 281 + pub fn fmt_pending(&self) -> String { 282 + let Self { 283 + links_pending, 284 + items_pending, 285 + links_resolved, 286 + items_resolved, 287 + } = self; 288 + 289 + let items = match (items_pending, items_resolved) { 290 + (a, 0) => plural!(a, "item"), 291 + (a, b) => format!("{a} out of {}", plural!(a + b, "item")), 292 + }; 293 + 294 + let links = match (links_pending, links_resolved) { 295 + (a, 0) => plural!(a, "link"), 296 + (a, b) => format!("{a} out of {}", plural!(a + b, "link")), 297 + }; 298 + 299 + format!("{links} containing {items}") 300 + } 301 + 302 + pub fn fmt_resolved(&self) -> String { 303 + let Self { 304 + links_pending, 305 + items_pending, 306 + links_resolved, 307 + items_resolved, 308 + } = self; 309 + 310 + let links = match (links_pending, links_resolved) { 311 + (0, b) => plural!(b, "link"), 312 + (a, b) => format!("{b} out of {}", plural!(a + b, "link")), 313 + }; 314 + 315 + let items = match (items_pending, items_resolved) { 316 + (0, b) => plural!(b, "item"), 317 + (a, b) => format!("{b} out of {}", plural!(a + b, "item")), 318 + }; 319 + 320 + format!("{links} containing {items}") 321 + } 322 + }
+44 -30
crates/mdbook-rustdoc-links/src/resolver.rs
··· 1 - use std::{borrow::Borrow, collections::HashMap, hash::Hash, sync::Arc}; 1 + use std::{borrow::Borrow, collections::HashMap, fmt::Write, hash::Hash, sync::Arc}; 2 2 3 3 use anyhow::{Context, Result}; 4 4 use lsp_types::Position; 5 - use tap::{Pipe, TapFallible}; 5 + use tap::Pipe; 6 6 use tokio::task::JoinSet; 7 - use tracing::{Instrument, Level, debug}; 7 + use tracing::{Instrument, Level, debug, info, instrument}; 8 8 9 - use mdbookkit::{emit_debug, ticker, ticker_item}; 9 + use mdbookkit::{ticker, ticker_item, url::UrlToPath}; 10 10 11 - use crate::{UNIQUE_ID, client::Client, item::Item, link::ItemLinks, page::Pages, url::UrlToPath}; 11 + use crate::{ 12 + UNIQUE_ID, 13 + client::Client, 14 + item::Item, 15 + link::{ItemLinks, LinkState}, 16 + page::{PageKey, Pages}, 17 + }; 12 18 13 19 /// Type that can provide links. 14 20 /// ··· 23 29 pub trait Resolver { 24 30 async fn resolve<K>(&self, pages: &mut Pages<'_, K>) -> Result<()> 25 31 where 26 - K: Eq + Hash; 32 + K: PageKey; 27 33 } 28 34 29 35 impl Resolver for Client { 36 + #[instrument(level = "debug", skip_all)] 30 37 async fn resolve<K>(&self, pages: &mut Pages<'_, K>) -> Result<()> 31 38 where 32 - K: Eq + Hash, 39 + K: PageKey, 33 40 { 34 - let request = pages.items(); 41 + let mut iter = pages.iter(); 35 42 36 - if request.is_empty() { 43 + let requests = iter.deduped(|link| match link.state() { 44 + LinkState::Pending(item) => Some(item), 45 + _ => None, 46 + }); 47 + 48 + if iter.stats().has_pending() { 49 + info!("Resolving {}", iter.stats().fmt_pending()); 50 + } else { 51 + debug!("no more items to resolve"); 37 52 return Ok(()); 38 53 } 39 54 40 - let main = std::fs::read_to_string(self.env().entrypoint.to_path()?)?; 55 + drop(iter); 56 + 57 + let main = self.env().entrypoint.expect_path(); 58 + let main = std::fs::read_to_string(&main) 59 + .with_context(|| format!("Reading {}", main.display())) 60 + .context("Failed to read from crate entrypoint")?; 41 61 42 62 let (context, request) = { 43 63 let mut context = format!("{main}\nfn {UNIQUE_ID} () {{\n"); 44 64 45 65 let line = context.chars().filter(|&c| c == '\n').count(); 46 66 47 - let request = request 48 - .iter() 67 + let request = requests 68 + .into_iter() 69 + .filter_map(|(k, v)| Some((k, v?))) 49 70 .scan(line, |line, (key, item)| { 50 71 build(&mut context, line, item).map(|cursors| (key.clone(), cursors)) 51 72 }) 52 73 .collect::<Vec<_>>(); 53 74 54 75 fn build(context: &mut String, line: &mut usize, item: &Item) -> Option<Vec<Position>> { 55 - use std::fmt::Write; 56 76 let _ = writeln!(context, "{}", item.stmt); 57 - let cursors = item 58 - .cursor 59 - .as_ref() 60 - .iter() 77 + let cursors = (item.cursor.as_ref().iter()) 61 78 .map(|&col| Position::new(*line as _, col as _)) 62 79 .collect::<Vec<_>>(); 63 80 *line += 1; ··· 69 86 (context, request) 70 87 }; 71 88 72 - debug!("request context\n\n{context}\n"); 89 + debug!("synthesized function\n\n{context}\n"); 73 90 74 91 let document = self 75 92 .open(self.env().entrypoint.clone(), context) 76 93 .await? 77 94 .pipe(Arc::new); 95 + 96 + info!("Finished indexing"); 78 97 79 98 let ticker = ticker!( 80 99 Level::INFO, ··· 86 105 let tasks: JoinSet<Option<(String, ItemLinks)>> = request 87 106 .into_iter() 88 107 .map(|(key, pos)| { 108 + let doc = document.clone(); 89 109 let key = key.to_string(); 90 - let doc = document.clone(); 91 - let ticker = ticker_item!(&ticker, Level::INFO, "resolve", "{key:?}"); 110 + let span = ticker_item!(&ticker, Level::INFO, "resolve", "{key:?}"); 92 111 async move { 93 112 for p in pos { 94 - let resolved = doc 95 - .resolve(p) 96 - .await 97 - .with_context(|| format!("{p:?}")) 98 - .context("failed to resolve symbol:") 99 - .tap_err(emit_debug!()) 100 - .ok(); 101 - if let Some(resolved) = resolved { 113 + if let Ok(resolved) = doc.resolve(p).await { 102 114 return Some((key, resolved)); 115 + } else { 116 + debug!("no result for {p:?}") 103 117 } 104 118 } 105 119 None 106 120 } 107 - .instrument(ticker) 121 + .instrument(span) 108 122 }) 109 123 .collect(); 110 124 ··· 128 142 { 129 143 async fn resolve<P>(&self, pages: &mut Pages<'_, P>) -> Result<()> 130 144 where 131 - P: Eq + Hash, 145 + P: PageKey, 132 146 { 133 147 pages.apply(self); 134 148 Ok(())
+53 -17
crates/mdbook-rustdoc-links/src/sync.rs
··· 10 10 task::JoinHandle, 11 11 time, 12 12 }; 13 + use tracing::{Instrument, trace, trace_span}; 13 14 14 - pub struct EventSampling<T> { 15 - pub buffer: Duration, 15 + use mdbookkit::error::ExpectLock; 16 + 17 + pub struct Debouncing<T> { 18 + pub debounce: Duration, 16 19 pub timeout: Duration, 17 20 pub receiver: mpsc::Receiver<Poll<T>>, 18 21 } 19 22 20 - impl<T> EventSampling<T> 23 + impl<T> Debouncing<T> 21 24 where 22 25 T: Clone + Send + Sync + 'static, 23 26 { 24 - pub fn build(self) -> EventSampler<T> { 27 + pub fn build(self) -> Debounce<T> { 28 + let state = Arc::new(RwLock::new(State::Pending)); 29 + let event = Arc::new(Notify::new()); 30 + 31 + let span = trace_span!("debounce", ?self); 32 + 25 33 let Self { 26 - buffer, 34 + debounce, 27 35 timeout, 28 36 mut receiver, 29 37 } = self; 30 38 31 - let state = Arc::new(RwLock::new(State::Pending)); 32 - let event = Arc::new(Notify::new()); 39 + tokio::spawn({ 40 + trace!("spawning debounce thread"); 33 41 34 - tokio::spawn({ 35 42 let state = state.clone(); 36 43 let event = event.clone(); 44 + let trace = span.id(); 45 + 37 46 async move { 38 47 let mut abort: Option<JoinHandle<()>> = None; 48 + 39 49 while let Some(value) = time::timeout(timeout, receiver.recv()).await.transpose() { 50 + trace!("received new event"); 51 + 40 52 if let Some(abort) = abort.take() { 53 + trace!("canceling deferred notification"); 41 54 abort.abort(); 42 55 } 56 + 43 57 match value { 44 58 Ok(Poll::Ready(value)) => { 59 + trace!("state is ready; deferring notification"); 45 60 let event = event.clone(); 46 61 let state = state.clone(); 62 + let trace = trace.clone(); 47 63 abort = Some(tokio::spawn(async move { 48 - time::sleep(buffer).await; 49 - *state.write().unwrap() = State::Ready(value); 64 + time::sleep(debounce).await; 65 + *state.write().expect_lock() = State::Ready(value); 66 + trace!(parent: trace, "state is ready; notifying"); 50 67 event.notify_waiters(); 51 68 })); 52 69 } 70 + 53 71 Ok(Poll::Pending) => { 54 - *state.write().unwrap() = State::Pending; 72 + trace!("state is pending"); 73 + *state.write().expect_lock() = State::Pending; 55 74 event.notify_waiters(); 56 75 } 76 + 57 77 Err(_) => { 58 - *state.write().unwrap() = State::Timeout; 78 + trace!("timed out waiting for state to become ready"); 79 + *state.write().expect_lock() = State::Timeout; 59 80 event.notify_waiters(); 60 81 } 61 82 } 62 83 } 63 84 } 85 + .instrument(span) 64 86 }); 65 87 66 - EventSampler { event, state } 88 + Debounce { event, state } 67 89 } 68 90 } 69 91 ··· 76 98 /// 77 99 /// [debouncing]: https://developer.mozilla.org/en-US/docs/Glossary/Debounce 78 100 #[derive(Debug, Clone)] 79 - pub struct EventSampler<T> { 101 + pub struct Debounce<T> { 80 102 state: Arc<RwLock<State<T>>>, 81 103 event: Arc<Notify>, 82 104 } ··· 88 110 Timeout, 89 111 } 90 112 91 - impl<T: Clone + Send + Sync + 'static> EventSampler<T> { 113 + impl<T: Clone + Send + Sync + 'static> Debounce<T> { 92 114 pub async fn wait(&self) -> Result<T> { 93 115 loop { 94 116 { 95 - match self.state.read().unwrap().clone() { 117 + match self.state.read().expect_lock().clone() { 96 118 State::Pending => {} 97 119 State::Ready(value) => { 98 120 return Ok(value); 99 121 } 100 122 State::Timeout => { 101 - bail!("timed out waiting for ready event") 123 + bail!("Timed out waiting for ready event") 102 124 } 103 125 } 104 126 } ··· 106 128 } 107 129 } 108 130 } 131 + 132 + impl<T> std::fmt::Debug for Debouncing<T> { 133 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 134 + let Self { 135 + debounce, 136 + timeout, 137 + receiver: _, 138 + } = &self; 139 + f.debug_struct("Debouncing") 140 + .field("debounce", &debounce) 141 + .field("timeout", &timeout) 142 + .finish_non_exhaustive() 143 + } 144 + }
+12 -7
crates/mdbook-rustdoc-links/src/tests.rs
··· 1 + #![allow(clippy::unwrap_used)] 2 + 1 3 use anyhow::{Context, Result, bail}; 2 4 use lsp_types::Url; 3 5 use rstest::*; ··· 10 12 use crate::{ 11 13 client::Client, 12 14 env::{Config, Environment}, 15 + link_report, 13 16 page::Pages, 14 17 resolver::Resolver, 15 18 }; ··· 34 37 ..Default::default() 35 38 } 36 39 .pipe(Environment::new) 37 - .context("failed to initialize environment") 40 + .context("Failed to initialize environment") 38 41 .unwrap() 39 42 .pipe(Client::new); 40 43 ··· 44 47 let stream = client.env().markdown(doc.content).into_offset_iter(); 45 48 pages 46 49 .read(doc.url(), doc.content, stream) 47 - .context("failed to parse source") 50 + .context("Failed to parse source") 48 51 .unwrap(); 49 52 } 50 53 ··· 56 59 client 57 60 .resolve(&mut pages) 58 61 .await 59 - .context("failed to resolve links") 62 + .context("Failed to resolve links") 60 63 .unwrap(); 61 64 client.stop().await 62 65 }); 63 66 67 + link_report(&pages); 68 + 64 69 Fixture { env, pages } 65 70 } 66 71 ··· 73 78 fn assert_report(doc: TestDocument, Fixture { pages, .. }: &Fixture) -> Result<()> { 74 79 let report = pages 75 80 .reporter() 76 - .level(LevelFilter::INFO) 77 - .named(|u| u == &doc.url()) 78 - .names(|_| doc.name()) 81 + .name_display(|_| doc.name()) 82 + .level_filter(LevelFilter::DEBUG) 83 + .filtered(|u| u == &doc.url()) 79 84 .build() 80 85 .to_report(); 81 86 ··· 104 109 .collect::<Vec<_>>(); 105 110 106 111 if !changed_lines.is_empty() { 107 - bail!("unexpected whitespace change: {changed_lines:?}") 112 + bail!("Unexpected whitespace change: {changed_lines:?}") 108 113 } else { 109 114 Ok(()) 110 115 }
+1 -1
crates/mdbook-rustdoc-links/src/tests/snaps/docs/src/rustdoc-links/getting-started.md.stderr.snap
··· 2 2 source: crates/mdbook-rustdoc-links/src/tests.rs 3 3 expression: report 4 4 --- 5 - info: successfully resolved all links 5 + info: link resolved 6 6 ╭─[docs/src/rustdoc-links/getting-started.md:55:6] 7 7 8 8 │ Like [`std::thread::spawn`], [`tokio::task::spawn`] returns a
+1 -1
crates/mdbook-rustdoc-links/src/tests/snaps/docs/src/rustdoc-links/index.md.stderr.snap
··· 2 2 source: crates/mdbook-rustdoc-links/src/tests.rs 3 3 expression: report 4 4 --- 5 - info: successfully resolved all links 5 + info: link resolved 6 6 ╭─[docs/src/rustdoc-links/index.md:25:5] 7 7 8 8 │ The [`option`][std::option] and [`result`][std::result] modules define optional and
+1 -1
crates/mdbook-rustdoc-links/src/tests/snaps/docs/src/rustdoc-links/known-issues.md.stderr.snap
··· 2 2 source: crates/mdbook-rustdoc-links/src/tests.rs 3 3 expression: report 4 4 --- 5 - info: successfully resolved all links 5 + info: link resolved 6 6 ╭─[docs/src/rustdoc-links/known-issues.md:27:25] 7 7 │ the derived trait by using the [macro syntax](supported-syntax.md#functions-and-macros), 8 8 │ for example, by writing [`[serde::Serialize!]`][serde::Serialize] instead of
+1 -1
crates/mdbook-rustdoc-links/src/tests/snaps/docs/src/rustdoc-links/supported-syntax.md.stderr.snap
··· 2 2 source: crates/mdbook-rustdoc-links/src/tests.rs 3 3 expression: report 4 4 --- 5 - info: successfully resolved all links 5 + info: link resolved 6 6 ╭─[docs/src/rustdoc-links/supported-syntax.md:32:10] 7 7 │ > 8 8 │ > Module [`alloc`][std::alloc] — Memory allocation APIs.
+30 -26
crates/mdbook-rustdoc-links/src/tests/snaps/ra-known-quirks.md.stderr.snap
··· 2 2 source: crates/mdbook-rustdoc-links/src/tests.rs 3 3 expression: report 4 4 --- 5 - warning: failed to resolve some links 6 - ╭─[ra-known-quirks.md:3:3] 7 - 8 - │ - [u8] 9 - · ──┬─ 10 - · ╰── https://doc.rust-lang.org/nightly/core/primitive.u8.html 11 - │ - [u32] 12 - · ──┬── 13 - · ╰── https://doc.rust-lang.org/nightly/core/primitive.u32.html 14 - │ - [f64] 15 - · ──┬── 16 - · ╰── https://doc.rust-lang.org/nightly/core/primitive.f64.html 17 - │ - [char] 18 - · ───┬── 19 - · ╰── https://doc.rust-lang.org/nightly/core/primitive.char.html 20 - │ - [str] 21 - · ──┬── 22 - · ╰── https://doc.rust-lang.org/nightly/core/primitive.str.html 5 + info: link resolved 6 + ╭─[ra-known-quirks.md:3:3] 7 + 8 + │ - [u8] 9 + · ──┬─ 10 + · ╰── https://doc.rust-lang.org/nightly/core/primitive.u8.html 11 + │ - [u32] 12 + · ──┬── 13 + · ╰── https://doc.rust-lang.org/nightly/core/primitive.u32.html 14 + │ - [f64] 15 + · ──┬── 16 + · ╰── https://doc.rust-lang.org/nightly/core/primitive.f64.html 17 + │ - [char] 18 + · ───┬── 19 + · ╰── https://doc.rust-lang.org/nightly/core/primitive.char.html 20 + │ - [str] 21 + · ──┬── 22 + · ╰── https://doc.rust-lang.org/nightly/core/primitive.str.html 23 + 24 + ╰──── 25 + ╭─[ra-known-quirks.md:16:3] 23 26 24 - │ # associated items on primitive types 27 + │ - [tokio::main!] 28 + · ───────┬────── 29 + · ╰── https://docs.rs/tokio-macros/2.5.0/tokio_macros/macro.main.html 30 + ╰──── 31 + 32 + warning: item could not be resolved 33 + ╭─[ra-known-quirks.md:11:3] 25 34 26 35 │ - [str::parse] 27 36 · ──────┬───── 28 - · ╰── failed to resolve link for "str::parse" 37 + · ╰── could not obtain a link to "str::parse" 29 38 │ - [f64::MIN_POSITIVE] 30 39 · ─────────┬───────── 31 - · ╰── failed to resolve link for "f64::MIN_POSITIVE" 32 - 33 - │ # macro_export 40 + · ╰── could not obtain a link to "f64::MIN_POSITIVE" 34 41 35 - │ - [tokio::main!] 36 - · ───────┬────── 37 - · ╰── https://docs.rs/tokio-macros/2.5.0/tokio_macros/macro.main.html 38 42 ╰────
-18
crates/mdbook-rustdoc-links/src/url.rs
··· 1 - use std::path::PathBuf; 2 - 3 - use anyhow::{bail, Result}; 4 - use lsp_types::Url; 5 - 6 - /// [`Url::to_file_path()`] with an actual [`std::error::Error`]. 7 - pub trait UrlToPath { 8 - fn to_path(&self) -> Result<PathBuf>; 9 - } 10 - 11 - impl UrlToPath for Url { 12 - fn to_path(&self) -> Result<PathBuf> { 13 - match self.to_file_path() { 14 - Ok(path) => Ok(path), 15 - Err(()) => bail!("failed to convert {self} to a file path"), 16 - } 17 - } 18 - }
+2 -2
crates/mdbookkit/Cargo.toml
··· 33 33 toml = { workspace = true } 34 34 tracing = { workspace = true } 35 35 tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } 36 + url = { workspace = true } 36 37 37 38 cargo-run-bin = { workspace = true, optional = true } 38 39 insta = { workspace = true, optional = true } 39 40 minijinja = { workspace = true, optional = true } 40 - url = { workspace = true, optional = true } 41 41 42 42 [features] 43 - _testing = ["dep:cargo-run-bin", "dep:insta", "dep:minijinja", "dep:url"] 43 + _testing = ["dep:cargo-run-bin", "dep:insta", "dep:minijinja"] 44 44 45 45 [package.metadata.docs.rs] 46 46 all-features = true
+10 -12
crates/mdbookkit/src/book.rs
··· 59 59 60 60 for (idx, name) in names.iter().enumerate() { 61 61 let name = format_name(name); 62 - if let Some(value) = (self.get::<T>(&name)) 63 - .with_context(|| format!("error while reading [{name}] in book.toml"))? 64 - { 62 + if let Some(value) = self.get::<T>(&name)? { 65 63 if idx != 0 { 66 64 let recommended = format_name(names[0]); 67 - warn!( 68 - "The book.toml section [{name}] is deprecated. \ 69 - Use [{recommended}] instead." 70 - ); 65 + warn! { "The book.toml section [{name}] is deprecated. \ 66 + Use [{recommended}] instead." }; 71 67 } 72 68 return Ok(value); 73 69 } ··· 137 133 138 134 fn to_stdout(self, ctx: &PreprocessorContext) -> Result<()> { 139 135 let output = if ctx.mdbook_version.starts_with("0.4.") { 140 - patch_mdbook_output_0_4(self)? 136 + patch_mdbook_output_0_4(self) 141 137 } else { 142 - serde_json::to_string(&self).context("failed to serialize mdbook output")? 143 - }; 138 + serde_json::to_string(&self).map_err(Into::into) 139 + } 140 + .context("Failed to serialize mdBook output")?; 144 141 std::io::stdout() 145 142 .write_all(output.as_bytes()) 146 - .context("failed to write mdbook output") 143 + .context("Failed to write mdBook output") 147 144 } 148 145 } 149 146 ··· 156 153 match ctx.get("mdbook_version") { 157 154 Some(Value::String(version)) => { 158 155 if !version.starts_with("0.4.") && !version.starts_with("0.5.") { 159 - bail!("unsupported mdbook version {version}; supported versions are 0.4, 0.5") 156 + bail! { "Unsupported mdBook version {version}; \ 157 + supported versions are 0.4, 0.5" } 160 158 } 161 159 } 162 160 _ => return Err(error)?,
+125 -133
crates/mdbookkit/src/diagnostics.rs
··· 2 2 3 3 use std::{ 4 4 borrow::Borrow, 5 + collections::BTreeMap, 5 6 fmt::{self, Write as _}, 6 7 io::Write as _, 7 8 }; 8 9 9 10 use miette::{ 10 11 Diagnostic, GraphicalReportHandler, GraphicalTheme, LabeledSpan, MietteError, 11 - MietteSpanContents, ReportHandler, Severity, SourceCode, SourceSpan, SpanContents, 12 + MietteSpanContents, Severity, SourceCode, SourceSpan, SpanContents, 12 13 }; 13 14 use owo_colors::Style; 14 - use tap::{Pipe, Tap}; 15 + use tap::{Pipe, Tap, TapFallible}; 15 16 use tracing::{Level, debug, error, info, level_filters::LevelFilter, trace, warn}; 16 17 17 18 use crate::{ 19 + emit_debug, 18 20 env::{is_colored, is_logging}, 21 + error::ExpectFmt, 19 22 logging::stderr, 20 23 }; 21 24 ··· 30 33 fn label(&self) -> LabeledSpan; 31 34 } 32 35 33 - /// Trait for diagnostics classes. This is like a specific error code. 34 - /// 35 - /// **For implementors:** The [`Display`][fmt::Display] implementation, which is the 36 - /// title of each diagnostic message, should use plurals whenever possible, because 37 - /// error reporters may elect to group together multiple labels of the same [`Issue`] 38 - pub trait Issue: Default + fmt::Debug + fmt::Display + Clone + Send + Sync { 36 + /// Trait for diagnostics classes, like an error code. 37 + pub trait Issue: fmt::Debug + Default + Clone + Send + Sync { 38 + fn title(&self) -> impl fmt::Display; 39 39 fn level(&self) -> Level; 40 40 } 41 41 ··· 79 79 .pipe(GraphicalReportHandler::new_themed); 80 80 81 81 let mut output = String::new(); 82 - handler.render_report(&mut output, self).unwrap(); 82 + handler.render_report(&mut output, self).expect_fmt(); 83 83 output 84 84 } 85 85 86 - /// Render the diagnostics as a list of log messages suitable for logging. 87 - pub fn to_logs(&self) -> String { 88 - let mut output = String::new(); 89 - LoggingReportHandler 90 - .render_report(&mut output, self) 91 - .unwrap(); 92 - output 86 + pub fn to_traces(&self) { 87 + for item in self.issues.iter() { 88 + let issue = item.issue(); 89 + let label = item.label(); 90 + let source = self 91 + .read_span(label.inner(), 0, 0) 92 + .expect("self.read_span infallible"); 93 + let path = source.name().unwrap_or("<anonymous>"); 94 + let line = source.line() + 1; 95 + let column = source.column() + 1; 96 + let title = issue.title(); 97 + let level = issue.level(); 98 + let label = label.label().unwrap_or_default(); 99 + let message = format_args!("{path}:{line}:{column}: {title}"); 100 + let message = if label.is_empty() { 101 + message 102 + } else { 103 + format_args!("{message}: {label}") 104 + }; 105 + if level >= Level::TRACE { 106 + trace!("{message}") 107 + } else if level >= Level::DEBUG { 108 + debug!("{message}") 109 + } else if level >= Level::INFO { 110 + info!("{message}") 111 + } else if level >= Level::WARN { 112 + warn!("{message}") 113 + } else { 114 + error!("{message}") 115 + } 116 + } 93 117 } 94 118 } 95 119 ··· 101 125 Self { text, name, issues } 102 126 } 103 127 104 - pub fn filtered(self, level: LevelFilter) -> Option<Self> { 105 - let Self { text, name, issues } = self; 106 - let issues = issues 107 - .into_iter() 108 - .filter(|p| p.issue().level() <= level) 109 - .collect::<Vec<_>>(); 110 - if issues.is_empty() { 111 - None 112 - } else { 113 - Some(Self { text, name, issues }) 114 - } 115 - } 116 - 117 128 pub fn name(&self) -> &K { 118 129 &self.name 119 130 } ··· 148 159 Some(Box::new(self.issues.iter().map(|p| p.label()))) 149 160 } 150 161 151 - fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> { 162 + fn help(&self) -> Option<Box<dyn fmt::Display + '_>> { 152 163 // miette doesn't print the file name if there are no labels to report 153 164 // so we print it here 154 165 if self.issues.is_empty() { ··· 194 205 195 206 impl<K, P: IssueItem> fmt::Display for Diagnostics<'_, K, P> { 196 207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 197 - fmt::Display::fmt(&self.status(), f) 208 + fmt::Display::fmt(&self.status().title(), f) 198 209 } 199 210 } 200 211 ··· 203 214 /// Builder for printing diagnostics over multiple files. 204 215 pub struct ReportBuilder<'a, K, P, F> { 205 216 items: Vec<Diagnostics<'a, K, P>>, 206 - print_name: F, 207 - log_filter: LevelFilter, 217 + name_display: F, 218 + level_filter: LevelFilter, 208 219 } 209 220 210 221 impl<'a, K, P, F> ReportBuilder<'a, K, P, F> { 211 - pub fn new(items: Vec<Diagnostics<'a, K, P>>, print_name: F) -> Self { 222 + pub fn new(items: Vec<Diagnostics<'a, K, P>>, name_display: F) -> Self { 212 223 Self { 213 224 items, 214 - print_name, 215 - log_filter: LevelFilter::TRACE, 225 + name_display, 226 + level_filter: max_level(), 216 227 } 217 228 } 218 229 219 230 /// Specify how file names should be printed. 220 - pub fn names<G>(self, print_name: G) -> ReportBuilder<'a, K, P, G> 231 + pub fn name_display<G>(self, name_display: G) -> ReportBuilder<'a, K, P, G> 221 232 where 222 233 G: for<'b> Fn(&'b K) -> String, 223 234 { 224 235 let Self { 225 - items, log_filter, .. 236 + items, 237 + level_filter, 238 + .. 226 239 } = self; 227 240 ReportBuilder { 228 241 items, 229 - print_name, 230 - log_filter, 242 + name_display, 243 + level_filter, 231 244 } 232 245 } 233 246 234 - pub fn level(mut self, level: LevelFilter) -> Self { 235 - self.log_filter = level; 247 + pub fn level_filter(mut self, level: LevelFilter) -> Self { 248 + self.level_filter = level; 236 249 self 237 250 } 238 251 239 - pub fn named<Q>(mut self, mut f: impl FnMut(&K) -> bool) -> Self 252 + pub fn filtered<Q>(mut self, mut f: impl FnMut(&K) -> bool) -> Self 240 253 where 241 254 K: Borrow<Q>, 242 255 Q: Eq + ?Sized, ··· 256 269 { 257 270 let Self { 258 271 items, 259 - print_name, 260 - log_filter, 272 + name_display, 273 + level_filter, 261 274 } = self; 262 275 263 276 let items = items 264 277 .into_iter() 265 - .filter_map(|p| { 266 - if p.status().level() > log_filter { 267 - return None; 268 - } 269 - 270 - let Diagnostics { text, name, issues } = p.filtered(log_filter)?; 271 - 272 - Some(Diagnostics { 273 - text, 274 - name: print_name(&name), 275 - issues, 276 - }) 278 + .flat_map(|Diagnostics { text, name, issues }| { 279 + Self::grouped(level_filter, issues) 280 + .into_iter() 281 + .map(|(level, issues)| { 282 + let name = name_display(&name); 283 + (level, Diagnostics { text, name, issues }) 284 + }) 285 + .collect::<Vec<_>>() 277 286 }) 278 - .collect::<Vec<_>>(); 287 + .collect::<Vec<_>>() 288 + .tap_mut(|items| { 289 + items.sort_by(|(l1, d1), (l2, d2)| (l2, &d1.name).cmp(&(l1, &d2.name))) 290 + }) 291 + .into_iter() 292 + .map(|(_, d)| d) 293 + .collect(); 279 294 280 295 Reporter { items } 296 + } 297 + 298 + fn grouped(max: LevelFilter, issues: Vec<P>) -> BTreeMap<Level, Vec<P>> { 299 + let mut groups = BTreeMap::<_, Vec<_>>::new(); 300 + for item in issues { 301 + let level = item.issue().level(); 302 + if level > max { 303 + continue; 304 + } 305 + groups.entry(level).or_default().push(item); 306 + } 307 + groups 281 308 } 282 309 } 283 310 ··· 303 330 } 304 331 305 332 if is_logging() { 306 - let logs = self.to_logs(); 307 - match self.to_status().level() { 308 - Level::TRACE => trace!("\n\n{logs}"), 309 - Level::DEBUG => debug!("\n\n{logs}"), 310 - Level::INFO => info!("\n\n{logs}"), 311 - Level::WARN => warn!("\n\n{logs}"), 312 - Level::ERROR => error!("\n\n{logs}"), 313 - } 333 + self.to_traces(); 314 334 } else { 315 - let report = self.to_report(); 316 - stderr().write_fmt(format_args!("\n\n{report}")).unwrap(); 335 + write!(stderr(), "\n{}", self.to_report()) 336 + .tap_err(emit_debug!()) 337 + .ok(); 317 338 }; 318 339 319 340 self ··· 321 342 322 343 pub fn to_report(&self) -> String { 323 344 self.items.iter().fold(String::new(), |mut out, diag| { 324 - writeln!(out, "{}", diag.to_report()).unwrap(); 345 + writeln!(out, "{}", diag.to_report()).expect_fmt(); 325 346 out 326 347 }) 327 348 } 328 349 329 - pub fn to_logs(&self) -> String { 330 - self.items.iter().fold(String::new(), |mut out, diag| { 331 - // TODO: flatten, skip heading 332 - writeln!(out, "{}", diag.to_logs()).unwrap(); 333 - out 334 - }) 335 - } 336 - } 337 - 338 - struct LoggingReportHandler; 339 - 340 - impl LoggingReportHandler { 341 - fn render_report(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result { 342 - let level = match diagnostic.severity() { 343 - Some(Severity::Error) | None => "error", 344 - Some(Severity::Warning) => "warning", 345 - Some(Severity::Advice) => "info", 346 - }; 347 - let code = if let Some(code) = diagnostic.code() { 348 - format!(" [{code}] ") 349 - } else { 350 - ": ".to_string() 351 - }; 352 - write!(f, "{level}{code}{diagnostic}")?; 353 - 354 - if let Some(help) = diagnostic.help() { 355 - write!(f, "\nhelp: {help}")?; 356 - } 357 - 358 - if let Some(url) = diagnostic.url() { 359 - write!(f, "\nsee: {url}")?; 360 - } 361 - 362 - let (Some(labels), Some(source)) = (diagnostic.labels(), diagnostic.source_code()) else { 363 - return Ok(()); 364 - }; 365 - 366 - for label in labels { 367 - let source = source 368 - .read_span(label.inner(), 0, 0) 369 - .map_err(|_| fmt::Error)?; 370 - let path = source.name().unwrap_or("<anonymous>"); 371 - let line = source.line() + 1; 372 - let column = source.column() + 1; 373 - if let Some(message) = label.label() { 374 - write!(f, "\n {path}:{line}:{column}: {message}")?; 375 - } else { 376 - write!(f, "\n {path}:{line}:{column}")?; 377 - } 378 - } 379 - 380 - Ok(()) 381 - } 382 - } 383 - 384 - impl ReportHandler for LoggingReportHandler { 385 - fn debug(&self, error: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result { 386 - if f.alternate() { 387 - fmt::Debug::fmt(error, f) 388 - } else { 389 - self.render_report(f, error) 350 + pub fn to_traces(&self) { 351 + for item in self.items.iter() { 352 + item.to_traces(); 390 353 } 391 354 } 392 355 } ··· 394 357 const fn level_style(level: Level) -> Style { 395 358 match level { 396 359 Level::TRACE => Style::new().dimmed(), 397 - Level::DEBUG => Style::new().magenta(), 360 + Level::DEBUG => Style::new().blue(), 398 361 Level::INFO => Style::new().green(), 399 362 Level::WARN => Style::new().yellow(), 400 363 Level::ERROR => Style::new().red(), 401 364 } 402 365 } 403 366 367 + /// [LevelFilter::current] always returns `TRACE` for some reason 368 + fn max_level() -> LevelFilter { 369 + if tracing::enabled!(Level::TRACE) { 370 + LevelFilter::TRACE 371 + } else if tracing::enabled!(Level::DEBUG) { 372 + LevelFilter::DEBUG 373 + } else if tracing::enabled!(Level::INFO) { 374 + LevelFilter::INFO 375 + } else if tracing::enabled!(Level::WARN) { 376 + LevelFilter::WARN 377 + } else { 378 + LevelFilter::ERROR 379 + } 380 + } 381 + 404 382 trait StyleCompat { 405 383 fn stderr(self) -> Self; 406 384 } ··· 411 389 } 412 390 } 413 391 414 - pub trait Title: Send + Sync + fmt::Display {} 392 + pub trait Title: fmt::Display + Send + Sync {} 415 393 416 - impl<K: Send + Sync + fmt::Display> Title for K {} 394 + impl<K: fmt::Display + Send + Sync> Title for K {} 395 + 396 + #[macro_export] 397 + macro_rules! plural { 398 + ( $num:expr, $singular:expr ) => { 399 + $crate::plural!($num, $singular, concat!($singular, "s")) 400 + }; 401 + ( $num:expr, $singular:expr, $plural:expr ) => {{ 402 + let num = $num; 403 + match num { 404 + 1 => format!("{num} {}", $singular), 405 + _ => format!("{num} {}", $plural), 406 + } 407 + }}; 408 + }
+5 -2
crates/mdbookkit/src/docs.rs
··· 122 122 .get_opts() 123 123 .filter(|opt| !opt.is_hide_set()) 124 124 .map(|opt| { 125 - let key = opt.get_long().unwrap().to_owned(); 125 + let key = opt 126 + .get_long() 127 + .expect("option should have a long name") 128 + .to_owned(); 126 129 127 130 let help = opt.get_help().map(|h| h.to_string()).unwrap_or_default(); 128 131 ··· 136 139 let type_id = if cfg!(debug_assertions) { 137 140 let ty = format!("{:?}", opt.get_value_parser().type_id()) 138 141 .replace("alloc::string::", ""); 139 - let name = ty.split("::").last().unwrap(); 142 + let name = ty.split("::").last().expect("split() shouldn't be empty"); 140 143 if matches!(action, ArgAction::Append) { 141 144 Some((format!("Vec<{name}>"), format!("Vec<{ty}>"))) 142 145 } else {
+103 -11
crates/mdbookkit/src/error.rs
··· 1 - use anyhow::{Result, anyhow}; 1 + use std::{fmt::Display, process::exit, sync::LockResult}; 2 + 3 + use anyhow::{Context, Error, Result, anyhow}; 2 4 use serde::Deserialize; 3 5 use tap::Pipe; 4 6 use tracing::Level; ··· 27 29 impl OnWarning { 28 30 pub fn check(&self, level: Level) -> Result<()> { 29 31 match level { 30 - Level::ERROR => Err(anyhow!("preprocessor has errors")), 32 + Level::ERROR => Err(anyhow!("Preprocessor has errors")), 31 33 Level::WARN => match self { 32 - Self::AlwaysFail => { 33 - anyhow!("treating warnings as errors because the `fail-on-warnings` option is set to \"always\"") 34 - .context("preprocessor has errors") 35 - .pipe(Err) 36 - } 34 + Self::AlwaysFail => anyhow! {"Treating warnings as errors because the \ 35 + `fail-on-warnings` option is set to \"always\""} 36 + .pipe(Err), 37 37 Self::FailInCi => { 38 38 let Some(ci) = is_ci() else { 39 39 return Ok(()); 40 40 }; 41 - anyhow!("treating warnings as errors because the `fail-on-warnings` option is set to \"ci\" and CI={ci}") 42 - .context("preprocessor has errors") 43 - .pipe(Err) 41 + anyhow! {"Treating warnings as errors because CI={ci} and the \ 42 + `fail-on-warnings` option is set to \"ci\""} 43 + .pipe(Err) 44 44 } 45 45 }, 46 46 _ => Ok(()), ··· 49 49 50 50 pub fn adjusted<T, E>(&self, result: Result<Result<T, E>, E>) -> Result<Result<T, E>, E> { 51 51 match result { 52 + Err(error) => Err(error), 52 53 Ok(Err(error)) if is_ci().is_some() => Err(error), 53 - result => result, 54 + Ok(Err(error)) => Ok(Err(error)), 55 + Ok(Ok(result)) => Ok(Ok(result)), 56 + } 57 + } 58 + } 59 + 60 + pub trait ExpectFmt { 61 + fn expect_fmt(self); 62 + } 63 + 64 + impl ExpectFmt for std::fmt::Result { 65 + #[inline(always)] 66 + fn expect_fmt(self) { 67 + self.expect("string formatting should not fail") 68 + } 69 + } 70 + 71 + pub trait ExpectLock<T> { 72 + fn expect_lock(self) -> T; 73 + } 74 + 75 + impl<T> ExpectLock<T> for LockResult<T> { 76 + #[inline(always)] 77 + fn expect_lock(self) -> T { 78 + self.expect("lock should not be poisoned") 79 + } 80 + } 81 + 82 + pub trait IntoAnyhow<T> { 83 + fn anyhow(self) -> Result<T>; 84 + } 85 + 86 + impl<T, E: Into<Error>> IntoAnyhow<T> for Result<T, E> { 87 + #[inline(always)] 88 + fn anyhow(self) -> Result<T> { 89 + self.map_err(Into::into) 90 + } 91 + } 92 + 93 + #[allow(async_fn_in_trait)] 94 + pub trait FutureWithError<T> { 95 + async fn context<C>(self, context: C) -> Result<T> 96 + where 97 + C: Display + Send + Sync + 'static; 98 + 99 + async fn with_context<C, G>(self, context: G) -> Result<T> 100 + where 101 + C: Display + Send + Sync + 'static, 102 + G: FnOnce() -> C; 103 + } 104 + 105 + impl<F, T, E> FutureWithError<T> for F 106 + where 107 + F: Future<Output = Result<T, E>>, 108 + E: Into<Error>, 109 + { 110 + #[inline(always)] 111 + async fn context<C>(self, context: C) -> Result<T> 112 + where 113 + C: Display + Send + Sync + 'static, 114 + { 115 + match self.await { 116 + Ok(value) => Ok(value), 117 + Err(error) => Err(error.into()).context(context), 118 + } 119 + } 120 + 121 + #[inline(always)] 122 + async fn with_context<C, G>(self, context: G) -> Result<T> 123 + where 124 + C: Display + Send + Sync + 'static, 125 + G: FnOnce() -> C, 126 + { 127 + match self.await { 128 + Ok(value) => Ok(value), 129 + Err(error) => Err(error.into()).with_context(context), 130 + } 131 + } 132 + } 133 + 134 + pub trait ExitProcess { 135 + fn exit(self, log: impl FnOnce(Error)) -> !; 136 + } 137 + 138 + impl ExitProcess for Result<()> { 139 + fn exit(self, log: impl FnOnce(Error)) -> ! { 140 + match self { 141 + Ok(()) => exit(0), 142 + Err(e) => { 143 + log(e); 144 + exit(1) 145 + } 54 146 } 55 147 } 56 148 }
+3
crates/mdbookkit/src/lib.rs
··· 1 + #![warn(clippy::unwrap_used)] 2 + 1 3 pub mod book; 2 4 pub mod diagnostics; 3 5 #[cfg(feature = "_testing")] ··· 8 10 pub mod markdown; 9 11 #[cfg(feature = "_testing")] 10 12 pub mod testing; 13 + pub mod url; 11 14 12 15 // referenced in docs 13 16 #[doc(hidden)]
+18 -10
crates/mdbookkit/src/logging.rs
··· 146 146 .with_default_directive(options.level.into()) 147 147 .parse_lossy(MDBOOK_LOG.as_deref().unwrap_or_default()); 148 148 149 + let max_level = filter.max_level_hint().unwrap_or(options.level); 150 + 149 151 let logger = tracing_subscriber::fmt::layer() 150 152 .compact() 151 153 .without_time() 152 - .with_target(filter.max_level_hint().unwrap_or(options.level) > LevelFilter::INFO) 154 + .with_file(max_level > LevelFilter::DEBUG) 155 + .with_line_number(max_level > LevelFilter::DEBUG) 156 + .with_target(is_colored() && max_level > LevelFilter::INFO) 153 157 .with_ansi(is_colored()) 154 158 .with_writer(|| TICKER.writer()) 155 159 .with_filter(if TICKER.is_enabled() { ··· 449 453 #[macro_export] 450 454 macro_rules! emit_trace { 451 455 () => { 452 - |err| ::tracing::trace!("{err:?}") 456 + |e| ::tracing::trace!("{:?}", e) 453 457 }; 454 458 ($fmt:expr) => { 455 459 |e| ::tracing::trace!($fmt, e) ··· 459 463 #[macro_export] 460 464 macro_rules! emit_debug { 461 465 () => { 462 - |err| ::tracing::debug!("{err:?}") 466 + |e| ::tracing::debug!("{:?}", e) 463 467 }; 464 468 ($fmt:expr) => { 465 469 |e| ::tracing::debug!($fmt, e) ··· 469 473 #[macro_export] 470 474 macro_rules! emit_warning { 471 475 () => { 472 - |e| { 473 - if ::tracing::enabled!(::tracing::Level::DEBUG) { 474 - ::tracing::warn!("{:?}", e) 475 - } else { 476 - ::tracing::warn!("{}", e) 477 - } 478 - } 476 + |e| ::tracing::warn!("{:?}", e) 479 477 }; 480 478 ($fmt:expr) => { 481 479 |e| ::tracing::warn!($fmt, e) 482 480 }; 483 481 } 482 + 483 + #[macro_export] 484 + macro_rules! emit_error { 485 + () => { 486 + |e| ::tracing::error!("{:?}", e) 487 + }; 488 + ($fmt:expr) => { 489 + |e| ::tracing::error!($fmt, e) 490 + }; 491 + }
+27 -14
crates/mdbookkit/src/markdown.rs
··· 5 5 use mdbook_markdown::pulldown_cmark::{Event, Options}; 6 6 use pulldown_cmark_to_cmark::{Error, cmark}; 7 7 use tap::Pipe; 8 + use tracing::{debug, trace, trace_span}; 9 + 10 + use crate::error::ExpectFmt; 8 11 9 12 /// _Patch_ a Markdown string, instead of regenerating it entirely, in order to preserve 10 13 /// as much of the original Markdown source as possible, especially with regard to whitespace. 11 14 /// 12 15 /// Currently, when using [`pulldown_cmark_to_cmark`] to generate Markdown from a 13 - /// [`pulldown_cmark::Event`][Event] stream, whitespace is NOT preserved. This is problematic 14 - /// for mdBook preprocessors, because preprocessors downstream may need to work on 16 + /// [`pulldown_cmark::Event`][Event] stream, whitespace is not preserved. This is problematic 17 + /// for mdBook preprocessors, because downstream preprocessors may need to work on 15 18 /// syntax that is whitespace-sensitive. Normalizing all whitespace could cause such 16 19 /// usage to no longer be recognized. 17 20 pub struct PatchStream<'a, S> { 18 21 source: &'a str, 19 22 stream: S, 20 - start: Option<usize>, 23 + range: Option<Range<usize>>, 21 24 patch: Option<String>, 22 25 } 23 26 ··· 29 32 type Item = Result<Cow<'a, str>, Error>; 30 33 31 34 fn next(&mut self) -> Option<Self::Item> { 32 - let start = self.start?; 35 + let range = self.range.clone()?; 33 36 34 37 if let Some(patch) = self.patch.take() { 38 + trace!("- {range:?} {:?}", &self.source[range.clone()]); 39 + trace!("+ {range:?} {patch:?}"); 35 40 return Some(Ok(Cow::Owned(patch))); 36 41 } 37 42 38 43 let Some((events, span)) = self.stream.next() else { 39 - self.start = None; 40 - return Some(Ok(Cow::Borrowed(&self.source[start..]))); 44 + let range = range.end..; 45 + trace!(" {range:?}"); 46 + trace!(" EOF"); 47 + self.range = None; 48 + return Some(Ok(Cow::Borrowed(&self.source[range]))); 41 49 }; 42 50 43 - if start > span.start { 44 - panic!("span {span:?} is backwards from already yielded span ending at {start}") 51 + if range.start > span.start { 52 + debug!("span {span:?} is before already yielded span {range:?}"); 53 + return Some(Err(Error::FormatFailed(Default::default()))); 45 54 } 46 55 47 - let patch = match String::new().pipe(|mut out| cmark(events, &mut out).and(Ok(out))) { 56 + let patch = match trace_span!("chunk", ?span) 57 + .in_scope(|| String::new().pipe(|mut out| cmark(events, &mut out).and(Ok(out)))) 58 + { 48 59 Err(error) => return Some(Err(error)), 49 60 Ok(patch) => patch, 50 61 }; 51 62 52 - self.start = Some(span.end); 63 + self.range = Some(span.clone()); 53 64 self.patch = Some(patch); 54 65 55 - Some(Ok(Cow::Borrowed(&self.source[start..span.start]))) 66 + let range = range.end..span.start; 67 + trace!(" {range:?}"); 68 + Some(Ok(Cow::Borrowed(&self.source[range]))) 56 69 } 57 70 } 58 71 59 72 impl<'a, S> PatchStream<'a, S> { 60 73 /// Create a new patch stream. 61 74 /// 62 - /// `stream` should be an [`Iterator`] yielding tuples of (`events`, `range`): 75 + /// `stream` should be an [`Iterator`] yielding tuples of `(events, range)`: 63 76 /// 64 77 /// - `events` is an [`Iterator`] yielding [`Event`]s which is the replacement 65 78 /// Markdown to be rendered into `source` using [`pulldown_cmark_to_cmark`]. ··· 77 90 Self { 78 91 source, 79 92 stream, 80 - start: Some(0), 93 + range: Some(0..0), 81 94 patch: None, 82 95 } 83 96 } ··· 91 104 pub fn into_string(self) -> Result<String, Error> { 92 105 let mut out = String::new(); 93 106 for chunk in self { 94 - write!(out, "{}", chunk?).unwrap(); 107 + write!(out, "{}", chunk?).expect_fmt(); 95 108 } 96 109 Ok(out) 97 110 }
+34 -20
crates/mdbookkit/src/testing.rs
··· 6 6 use tracing::info; 7 7 use url::Url; 8 8 9 + use crate::url::{ExpectUrl, UrlFromPath, UrlToPath}; 10 + 9 11 #[derive(Debug, PartialEq, Eq, Hash)] 10 12 pub struct TestDocument { 11 13 pub source_path: &'static str, ··· 28 30 pub fn cwd(&self) -> Url { 29 31 CARGO_WORKSPACE_DIR 30 32 .join(self.source_path) 31 - .unwrap() 33 + .expect_url() 32 34 .join(".") 33 - .unwrap() 35 + .expect_url() 34 36 } 35 37 36 38 pub fn url(&self) -> Url { 37 - self.cwd().join(self.target_path).unwrap() 39 + self.cwd().join(self.target_path).expect_url() 38 40 } 39 41 40 42 pub fn name(&self) -> String { 41 43 let dir = Path::new(self.source_path) 42 44 .with_extension("") 43 45 .file_name() 44 - .unwrap() 46 + .expect("source_path should have a file name") 45 47 .to_string_lossy() 46 48 .into_owned(); 47 49 48 50 let url = self.url(); 49 - let cwd = self.cwd().join(&format!("{dir}/")).unwrap(); 50 - let rel = cwd.make_relative(&url).unwrap(); 51 + let cwd = self.cwd().join(&format!("{dir}/")).expect_url(); 52 + let rel = cwd.make_relative(&url).expect("both are file: URLs"); 51 53 52 54 if rel.starts_with("../") { 53 - let rel = CARGO_WORKSPACE_DIR.make_relative(&url).unwrap(); 55 + let rel = CARGO_WORKSPACE_DIR 56 + .make_relative(&url) 57 + .expect("both are file: URLs"); 54 58 if rel.starts_with("../") { 55 - url.path_segments().unwrap().next_back().unwrap().to_owned() 59 + url.path_segments() 60 + .expect("file: URL") 61 + .next_back() 62 + .expect("URL path not empty") 63 + .to_owned() 56 64 } else { 57 65 rel 58 66 } ··· 89 97 90 98 let path = source_path.with_extension("").join("snaps"); 91 99 let path = CARGO_WORKSPACE_DIR 92 - .to_file_path() 93 - .unwrap() 94 - .join(&*path.to_string_lossy()) 95 - .join(name.parent().unwrap()); 100 + .expect_path() 101 + .join(&*path.to_string_lossy()); 102 + let path = if let Some(parent) = name.parent() { 103 + path.join(parent) 104 + } else { 105 + path 106 + }; 96 107 97 108 let name = name 98 109 .file_name() ··· 133 144 cargo_run_bin::metadata::get_binary_packages()? 134 145 .into_iter() 135 146 .map(cargo_run_bin::binary::install) 136 - .map(|path| Ok(Path::new(&path?).parent().unwrap().to_owned())) 147 + .map(|path| { 148 + Ok(Path::new(&path?) 149 + .parent() 150 + .expect("install path should not be root") 151 + .to_owned()) 152 + }) 137 153 .collect::<Result<Vec<_>>>()?, 138 154 ); 139 155 140 156 path.push( 141 157 CARGO_WORKSPACE_DIR 142 158 .join("target")? 143 - .to_file_path() 144 - .unwrap() 159 + .expect_path() 145 160 .join(if cfg!(debug_assertions) { 146 161 "debug" 147 162 } else { ··· 163 178 .args(["--format-version=1", "--no-deps"]) 164 179 .current_dir(env!("CARGO_MANIFEST_DIR")) 165 180 .output() 166 - .unwrap() 181 + .expect("cargo metadata must not fail") 167 182 .pipe(|output| String::from_utf8(output.stdout)) 168 - .unwrap() 183 + .expect("cargo metadata should output in utf8") 169 184 .pipe(|output| serde_json::from_str::<CargoManifest>(&output)) 170 - .unwrap() 171 - .pipe(|manifest| Url::from_directory_path(manifest.workspace_root)) 172 - .unwrap() 185 + .expect("cargo metadata format should be correct") 186 + .pipe(|manifest| manifest.workspace_root.to_directory_url()) 173 187 }); 174 188 175 189 pub fn not_in_ci<D: std::fmt::Display>(because: D) -> bool {
+54
crates/mdbookkit/src/url.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + 3 + use anyhow::{Result, bail}; 4 + use url::Url; 5 + 6 + pub trait ExpectUrl<T> { 7 + fn expect_url(self) -> T; 8 + } 9 + 10 + impl<T> ExpectUrl<T> for Result<T, url::ParseError> { 11 + #[inline(always)] 12 + fn expect_url(self) -> T { 13 + self.expect("should be a valid URL") 14 + } 15 + } 16 + 17 + pub trait UrlToPath { 18 + fn to_path(&self) -> Result<PathBuf>; 19 + 20 + fn expect_path(&self) -> PathBuf; 21 + } 22 + 23 + impl UrlToPath for Url { 24 + #[inline(always)] 25 + fn to_path(&self) -> Result<PathBuf> { 26 + match self.to_file_path() { 27 + Ok(path) => Ok(path), 28 + Err(_) => bail!("{self} does not have a valid file path"), 29 + } 30 + } 31 + 32 + #[inline(always)] 33 + fn expect_path(&self) -> PathBuf { 34 + self.to_path().expect("URL path should be valid") 35 + } 36 + } 37 + 38 + pub trait UrlFromPath { 39 + fn to_directory_url(&self) -> Url; 40 + 41 + fn to_file_url(&self) -> Url; 42 + } 43 + 44 + impl<P: AsRef<Path> + ?Sized> UrlFromPath for P { 45 + #[inline(always)] 46 + fn to_directory_url(&self) -> Url { 47 + Url::from_directory_path(self).expect("should be a valid absolute path") 48 + } 49 + 50 + #[inline(always)] 51 + fn to_file_url(&self) -> Url { 52 + Url::from_file_path(self).expect("should be a valid absolute path") 53 + } 54 + }
+1 -1
docs/bin/main.rs
··· 8 8 }; 9 9 10 10 fn preprocess() -> Result<()> { 11 - let (ctx, mut book) = book_from_stdin().context("failed to read from mdbook")?; 11 + let (ctx, mut book) = book_from_stdin().context("Failed to read from mdBook")?; 12 12 13 13 book.for_each_text_mut(|_, content| { 14 14 let stream = Parser::new(content)
+5 -5
utils/mdbook-socials/src/main.rs
··· 13 13 use clap::Parser; 14 14 use glob::glob; 15 15 use lol_html::{ 16 - element, html_content::ContentType, rewrite_str, text, HtmlRewriter, RewriteStrSettings, 17 - Settings, 16 + HtmlRewriter, RewriteStrSettings, Settings, element, html_content::ContentType, rewrite_str, 17 + text, 18 18 }; 19 19 use minijinja::Environment; 20 20 use serde::Deserialize; ··· 61 61 let image = book_toml_path.join(&image)?; 62 62 let image = src_dir 63 63 .make_relative(&image) 64 - .context("failed to make relative path to image")?; 64 + .context("Failed to make relative path to image")?; 65 65 book_toml.preprocessor.link_forever.book_url.join(&image)? 66 66 }; 67 67 let metadata = PageMetadata { ··· 104 104 105 105 let pathname = out_dir 106 106 .make_relative(&url) 107 - .context("failed to get page pathname")? 107 + .context("Failed to get page pathname")? 108 108 .replace("index.html", "") 109 109 .replace(".html", "") 110 110 .pipe(|p| format!("/{p}")); ··· 209 209 <meta name="twitter:card" content="summary_large_image"> 210 210 <meta name="twitter:title" content="{{ og_title }}"> 211 211 <meta name="twitter:image" content="{{ og_image }}"> 212 - <meta name="twitter:image:alt" content="toolkit for mdbook"> 212 + <meta name="twitter:image:alt" content="toolkit for mdBook"> 213 213 <meta name="twitter:description" content="{{ og_description }}"> 214 214 <meta name="theme-color" content="#d2a6ff"> 215 215 "##;