cargo_rdme/transform/intralinks/
mod.rs1use crate::transform::DocTransform;
2use crate::transform::intralinks::links::{
3 Link, MarkdownInlineLink, MarkdownLink, MarkdownReferenceLink, markdown_link_iterator,
4 markdown_reference_link_definition_iterator,
5};
6pub use crate::transform::intralinks::rustdoc::{
7 EXPECTED_RUST_TOOLCHAIN, install_expected_rust_toolchain, is_expected_rust_toolchain_installed,
8};
9use crate::transform::intralinks::rustdoc::{IntralinkResolver, create_intralink_resolver};
10use crate::{Doc, PackageTarget};
11use itertools::Itertools;
12use std::borrow::Cow;
13use std::collections::HashSet;
14use std::fmt::Display;
15use std::path::PathBuf;
16use thiserror::Error;
17use unicase::UniCase;
18
19mod links;
20mod rustdoc;
21
22#[derive(Error, Debug)]
23pub enum IntralinkError {
24 #[error("failed to run rustdoc: {error}")]
25 RustdocError {
26 #[source]
27 error: rustdoc_json::BuildError,
28 },
29 #[error("failed to run rustdoc:\n{stderr}")]
30 BuildRustdocError { stderr: String },
31 #[error("failed to read rustdoc json file: {io_error}")]
32 ReadRustdocError {
33 #[source]
34 io_error: std::io::Error,
35 },
36 #[error("failed to parse rustdoc json file: {serde_error}")]
37 ParseRustdocError { serde_error: serde_json::Error },
38 #[error("unsupported rustdoc format version {version} (expected version {expected_version})")]
39 UnsupportedRustdocFormatVersion { version: u32, expected_version: u32 },
40 #[error(
41 "rust toolchain not installed: {expected}\n\n\
42 `cargo-rdme` needs {expected} to do intralink resolution. To install it run:\n\n \
43 rustup toolchain install {expected}\n\n\
44 or, equivalently, run `cargo rdme install-rust-toolchain-for-intralinks`."
45 )]
46 RustToolchainNotInstalled { expected: &'static str },
47 #[error("failed to run rustup toolchain: {error}")]
48 RustupToolchain { error: rustup_toolchain::Error },
49}
50
51#[derive(Debug, PartialEq, Eq, Clone)]
58pub enum IntralinksDocsConfig {
59 DocsRs {
60 base_url: Option<String>,
62 version: Option<String>,
64 },
65 Flat {
66 base_url: String,
68 },
69}
70
71impl Default for IntralinksDocsConfig {
72 fn default() -> IntralinksDocsConfig {
73 IntralinksDocsConfig::DocsRs { base_url: None, version: None }
74 }
75}
76
77#[derive(Default, Debug, PartialEq, Eq, Clone)]
78pub struct IntralinksConfig {
79 pub docs: IntralinksDocsConfig,
80 pub strip_links: Option<bool>,
81 pub all_features: Option<bool>,
82 pub features: Option<Vec<String>>,
83 pub no_default_features: Option<bool>,
84 pub rustdoc_toolchain: Option<String>,
85}
86
87pub struct DocTransformIntralinks<F> {
88 package_name: String,
89 package_target: PackageTarget,
90 workspace_package: Option<String>,
91 manifest_path: PathBuf,
92 emit_warning: F,
93 config: IntralinksConfig,
94}
95
96impl<F> DocTransformIntralinks<F>
97where
98 F: Fn(&str),
99{
100 pub fn new(
101 package_name: impl Into<String>,
102 package_target: PackageTarget,
103 workspace_package: Option<String>,
104 manifest_path: PathBuf,
105 emit_warning: F,
106 config: Option<IntralinksConfig>,
107 ) -> DocTransformIntralinks<F> {
108 DocTransformIntralinks {
109 package_name: package_name.into(),
110 package_target,
111 workspace_package,
112 manifest_path,
113 emit_warning,
114 config: config.unwrap_or_default(),
115 }
116 }
117}
118
119#[derive(PartialEq, Eq, Hash, Clone, Debug)]
120struct ItemPath<'a> {
121 segments: Cow<'a, [String]>,
122}
123
124impl<'a> ItemPath<'a> {
125 fn new(segments: &'a [String]) -> ItemPath<'a> {
126 assert!(!segments.is_empty(), "path item must not be empty");
127
128 ItemPath { segments: Cow::Borrowed(segments) }
129 }
130
131 fn add(&self, segment: String) -> ItemPath<'static> {
132 let mut segments = self.segments.clone().into_owned();
133
134 segments.push(segment);
135
136 ItemPath { segments: Cow::Owned(segments) }
137 }
138
139 fn parent(&self) -> Option<ItemPath<'_>> {
140 match self.segments.len() {
141 0 | 1 => None,
142 len => Some(ItemPath { segments: Cow::Borrowed(&self.segments[..len - 1]) }),
143 }
144 }
145
146 fn segments(&self) -> impl Iterator<Item = &str> {
147 self.segments.iter().map(String::as_str)
148 }
149
150 fn len(&self) -> usize {
151 self.segments.len()
152 }
153}
154
155impl Display for ItemPath<'_> {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 let iter = Itertools::intersperse(self.segments.iter().map(String::as_str), "::");
159
160 for s in iter {
161 f.write_str(s)?;
162 }
163
164 Ok(())
165 }
166}
167
168fn has_intralinks(doc: &Doc) -> bool {
169 let link_targets =
170 markdown_link_iterator(&doc.markdown).items().filter_map(|link| match link {
171 MarkdownLink::Inline { link } => Some(link.link),
172 MarkdownLink::Reference { link: MarkdownReferenceLink::Shortcut { text } }
173 if is_intralink_shortcut(text.as_str()) =>
174 {
175 Some(Link::new(text.as_str().to_owned()))
176 }
177 MarkdownLink::Reference { .. } => None,
178 });
179 let reference_links = markdown_reference_link_definition_iterator(&doc.markdown)
180 .items()
181 .map(|link_def| link_def.link);
182
183 link_targets.chain(reference_links).any(|link| IntralinkResolver::is_intralink(&link))
184}
185
186impl<F> DocTransform for DocTransformIntralinks<F>
187where
188 F: Fn(&str),
189{
190 type E = IntralinkError;
191
192 fn transform(&self, doc: &Doc) -> Result<Doc, IntralinkError> {
193 if !has_intralinks(doc) {
195 return Ok(doc.clone());
196 }
197
198 let strip_links = self.config.strip_links.unwrap_or(false);
199
200 let intralink_resolver: IntralinkResolver<'_> = match strip_links {
201 true => {
202 IntralinkResolver::new(self.package_name.as_str(), &self.config.docs)
204 }
205 false => create_intralink_resolver(
206 self.package_name.as_str(),
207 &self.package_target,
208 self.workspace_package.as_deref(),
209 &self.manifest_path,
210 &self.config,
211 )?,
212 };
213
214 let doc = rewrite_links(doc, &intralink_resolver, &self.emit_warning, &self.config);
215
216 Ok(doc)
217 }
218}
219
220fn rewrite_links(
221 doc: &Doc,
222 intralink_resolver: &IntralinkResolver,
223 emit_warning: &impl Fn(&str),
224 config: &IntralinksConfig,
225) -> Doc {
226 let RewriteReferenceLinksResult { doc, reference_links_to_remove } =
227 rewrite_reference_links_definitions(doc, intralink_resolver, emit_warning, config);
228
229 rewrite_markdown_links(
230 &doc,
231 intralink_resolver,
232 emit_warning,
233 config,
234 &reference_links_to_remove,
235 )
236}
237
238enum MarkdownLinkAction {
239 Link(Link),
240 Preserve,
241 Strip,
242}
243
244fn ensure_backticked(text: &str) -> String {
245 let is_backticked = text.len() >= 2 && text.starts_with('`') && text.ends_with('`');
246
247 match is_backticked {
248 true => text.to_owned(),
249 false => format!("`{text}`"),
250 }
251}
252
253fn is_intralink_shortcut(text: &str) -> bool {
261 let backticked = text.len() >= 2 && text.starts_with('`') && text.ends_with('`');
262 let has_path_separator = text.contains("::");
263 let is_bare_identifier =
264 !text.is_empty() && text.chars().all(|c| c.is_alphanumeric() || c == '_');
265
266 backticked || has_path_separator || is_bare_identifier
267}
268
269fn resolve_shortcut_intralink(
270 link: &MarkdownReferenceLink,
271 intralink_resolver: &IntralinkResolver,
272 strip_links: bool,
273) -> Option<MarkdownLinkAction> {
274 let MarkdownReferenceLink::Shortcut { text } = link else {
275 return None;
276 };
277
278 if strip_links {
279 return is_intralink_shortcut(text.as_str()).then_some(MarkdownLinkAction::Strip);
280 }
281
282 let candidate = Link::new(text.as_str().to_owned());
283 let url = intralink_resolver.resolve_link(&candidate)?;
284
285 let url = match candidate.link_fragment() {
286 Some(fragment) if !url.contains('#') => format!("{url}#{fragment}"),
287 _ => url.to_owned(),
288 };
289
290 Some(MarkdownLinkAction::Link(url.into()))
291}
292
293fn markdown_link(
294 link: &Link,
295 intralink_resolver: &IntralinkResolver,
296 emit_warning: &impl Fn(&str),
297) -> MarkdownLinkAction {
298 assert!(IntralinkResolver::is_intralink(link));
299
300 match intralink_resolver.resolve_link(link) {
301 None => {
302 emit_warning(&format!("Could not resolve definition of `{}`.", link.symbol()));
303
304 MarkdownLinkAction::Strip
305 }
306 Some(url) => {
307 let url = match link.link_fragment() {
311 Some(fragment) if !url.contains('#') => format!("{url}#{fragment}"),
312 _ => url.to_owned(),
313 };
314
315 MarkdownLinkAction::Link(url.into())
316 }
317 }
318}
319
320fn rewrite_markdown_links(
321 doc: &Doc,
322 intralink_resolver: &IntralinkResolver,
323 emit_warning: &impl Fn(&str),
324 config: &IntralinksConfig,
325 reference_links_to_remove: &HashSet<UniCase<String>>,
326) -> Doc {
327 use crate::utils::ItemOrOther;
328
329 let strip_links = config.strip_links.unwrap_or(false);
330 let mut new_doc = String::with_capacity(doc.as_string().len() + 1024);
331
332 for item_or_other in markdown_link_iterator(&doc.markdown).complete() {
333 match item_or_other {
334 ItemOrOther::Item(MarkdownLink::Inline { link: inline_link }) => {
335 let markdown_link: MarkdownLinkAction =
336 match IntralinkResolver::is_intralink(&inline_link.link) {
337 true => match strip_links {
338 false => {
339 markdown_link(&inline_link.link, intralink_resolver, emit_warning)
340 }
341 true => MarkdownLinkAction::Strip,
342 },
343 false => MarkdownLinkAction::Preserve,
344 };
345
346 match markdown_link {
347 MarkdownLinkAction::Link(markdown_link) => {
348 new_doc.push_str(&inline_link.with_link(markdown_link).to_string());
349 }
350 MarkdownLinkAction::Preserve => {
351 new_doc.push_str(&inline_link.to_string());
352 }
353 MarkdownLinkAction::Strip => {
354 new_doc.push_str(&inline_link.text);
355 }
356 }
357 }
358 ItemOrOther::Item(MarkdownLink::Reference { link }) => {
359 if reference_links_to_remove.contains(link.label()) {
360 new_doc.push_str(link.text());
361 } else if let Some(action) =
362 resolve_shortcut_intralink(&link, intralink_resolver, strip_links)
363 {
364 let backticked = ensure_backticked(link.text());
367
368 match action {
369 MarkdownLinkAction::Link(resolved) => {
370 let inline = MarkdownInlineLink { text: backticked, link: resolved };
371
372 new_doc.push_str(&inline.to_string());
373 }
374 MarkdownLinkAction::Strip => new_doc.push_str(&backticked),
375 MarkdownLinkAction::Preserve => new_doc.push_str(&link.to_string()),
376 }
377 } else {
378 new_doc.push_str(&link.to_string());
379 }
380 }
381 ItemOrOther::Other(other) => {
382 new_doc.push_str(other);
383 }
384 }
385 }
386
387 Doc::from_str(new_doc)
388}
389
390struct RewriteReferenceLinksResult {
391 doc: Doc,
392 reference_links_to_remove: HashSet<UniCase<String>>,
393}
394
395fn rewrite_reference_links_definitions(
396 doc: &Doc,
397 intralink_resolver: &IntralinkResolver,
398 emit_warning: &impl Fn(&str),
399 config: &IntralinksConfig,
400) -> RewriteReferenceLinksResult {
401 use crate::utils::ItemOrOther;
402 let mut reference_links_to_remove = HashSet::new();
403 let mut new_doc = String::with_capacity(doc.as_string().len() + 1024);
404 let mut skip_next_newline = false;
405 let strip_links = config.strip_links.unwrap_or(false);
406
407 let iter = markdown_reference_link_definition_iterator(&doc.markdown);
408
409 for item_or_other in iter.complete() {
410 match item_or_other {
411 ItemOrOther::Item(link_ref_def) => {
412 let markdown_link: MarkdownLinkAction =
413 match IntralinkResolver::is_intralink(&link_ref_def.link) {
414 true => match strip_links {
415 false => {
416 markdown_link(&link_ref_def.link, intralink_resolver, emit_warning)
417 }
418 true => MarkdownLinkAction::Strip,
419 },
420 false => MarkdownLinkAction::Preserve,
421 };
422
423 match markdown_link {
424 MarkdownLinkAction::Link(link) => {
425 new_doc.push_str(&link_ref_def.with_link(link).to_string());
426 }
427 MarkdownLinkAction::Preserve => {
428 new_doc.push_str(&link_ref_def.to_string());
429 }
430 MarkdownLinkAction::Strip => {
431 reference_links_to_remove.insert(link_ref_def.label);
433 skip_next_newline = true;
434 }
435 }
436 }
437 ItemOrOther::Other(other) => {
438 let other = match skip_next_newline {
439 true => {
440 skip_next_newline = false;
441 let next_index = other
442 .chars()
443 .enumerate()
444 .skip_while(|(_, c)| c.is_whitespace() && *c != '\n')
445 .skip(1)
446 .map(|(i, _)| i)
447 .next();
448
449 next_index.and_then(|i| other.get(i..)).unwrap_or("")
450 }
451 false => other,
452 };
453 new_doc.push_str(other);
454 }
455 }
456 }
457
458 RewriteReferenceLinksResult { doc: Doc::from_str(new_doc), reference_links_to_remove }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use pretty_assertions::assert_eq;
465
466 #[test]
467 fn test_ensure_backticked() {
468 assert_eq!(ensure_backticked("Foo"), "`Foo`");
469 assert_eq!(ensure_backticked("foo::Bar"), "`foo::Bar`");
470 assert_eq!(ensure_backticked("`Foo`"), "`Foo`");
471 assert_eq!(ensure_backticked("`foo::Bar`"), "`foo::Bar`");
472 assert_eq!(ensure_backticked(""), "``");
473 assert_eq!(ensure_backticked("`"), "```");
475 assert_eq!(ensure_backticked("``"), "``");
477 assert_eq!(ensure_backticked("a`b"), "`a`b`");
479 }
480
481 #[test]
482 fn test_is_intralink_shortcut() {
483 assert!(is_intralink_shortcut("Foo"));
485 assert!(is_intralink_shortcut("foo"));
486 assert!(is_intralink_shortcut("_foo"));
487 assert!(is_intralink_shortcut("Foo123"));
488
489 assert!(is_intralink_shortcut("foo::Bar"));
491 assert!(is_intralink_shortcut("crate::foo::Bar"));
492 assert!(is_intralink_shortcut("a b::c")); assert!(is_intralink_shortcut("`Foo`"));
496 assert!(is_intralink_shortcut("`foo::Bar`"));
497 assert!(is_intralink_shortcut("`Foo()`"));
498 assert!(is_intralink_shortcut("`Foo!`"));
499
500 assert!(!is_intralink_shortcut("some text"));
502 assert!(!is_intralink_shortcut("Foo!"));
503 assert!(!is_intralink_shortcut("Foo()"));
504 assert!(!is_intralink_shortcut(""));
505 assert!(!is_intralink_shortcut("`Foo")); }
507}