Skip to main content

cargo_rdme/transform/intralinks/
mod.rs

1use 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/// Layout of the documentation URLs produced for intralinks.
52///
53/// [`IntralinksDocsConfig::DocsRs`] follows the `docs.rs` scheme, inserting a `{crate}/{version}`
54/// segment between the base URL and the item path. [`IntralinksDocsConfig::Flat`] omits those
55/// segments, appending the item path directly to the base URL: this matches the layout produced
56/// by a local `cargo doc` build and hosting setups that mirror it.
57#[derive(Debug, PartialEq, Eq, Clone)]
58pub enum IntralinksDocsConfig {
59    DocsRs {
60        /// When `None`, defaults to `https://docs.rs`.
61        base_url: Option<String>,
62        /// When `None`, defaults to `latest`.
63        version: Option<String>,
64    },
65    Flat {
66        /// Base URL to prepend to the item path.
67        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        // TODO Use standard library intersperse() one it stabilizes (https://github.com/rust-lang/rust/issues/79524).
158        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 there are no intralinks return immediately. No need to run `rustdoc` at all.
194        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                // Create an empty resolver, since we are going to strip all intralinks.
203                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
253/// A shortcut link `[text]` looks like a Rust intralink if it is wrapped in backticks
254/// (`` [`Foo`] ``), contains a path separator (`[foo::Bar]`), or is a bare identifier (`[Foo]`).
255/// The three conditions overlap by design: backticked text typically wraps either an identifier
256/// or a path. We check each independently to keep the logic readable.
257///
258/// Intentionally permissive: in non-strip mode, false positives just miss in the resolver and
259/// fall through; in strip mode (where the resolver is empty), this is the only signal we have.
260fn 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            // For impl items the resolved URL already carries an implicit fragment (e.g.
308            // `#method.foo`), so we drop any user-supplied fragment to avoid emitting a URL with
309            // two `#`s.
310            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                    // Shortcut intralinks render as code in rustdoc HTML; preserve that by
365                    // ensuring the link text is backticked in the README output.
366                    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                        // Do not emit anything to new_doc.
432                        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        // A lone backtick is not a matched pair.
474        assert_eq!(ensure_backticked("`"), "```");
475        // Already-paired empty backticks stay as is.
476        assert_eq!(ensure_backticked("``"), "``");
477        // Inner backticks are not stripped.
478        assert_eq!(ensure_backticked("a`b"), "`a`b`");
479    }
480
481    #[test]
482    fn test_is_intralink_shortcut() {
483        // Bare identifiers.
484        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        // Path-separated forms.
490        assert!(is_intralink_shortcut("foo::Bar"));
491        assert!(is_intralink_shortcut("crate::foo::Bar"));
492        assert!(is_intralink_shortcut("a b::c")); // whitespace ok if `::` is present.
493
494        // Backticked forms.
495        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        // Rejected: not intralink-shaped.
501        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")); // unmatched backtick, no `::`, has backtick.
506    }
507}