Skip to main content

miniextendr_lint/
doc_links.rs

1//! The doc-comment text of `#[miniextendr]` items that reaches roxygen2 as
2//! markdown, and the `pkg::topic` links inside it (MXL204).
3//!
4//! The walk mirrors `miniextendr-macros/src/roxygen.rs`: leading prose comes
5//! first, then `@tag` sections. The text of a code tag (`@examples`, ...) is not
6//! markdown, and the lines after a single-line tag (`@export`, ...) stay in
7//! rustdoc only. Fenced code blocks are skipped. Keep `MULTILINE_TAGS` and
8//! `CODE_TAGS` in sync with that file.
9
10use syn::Attribute;
11use syn::spanned::Spanned;
12
13/// Tags whose text runs over several lines (`MULTILINE_TAGS` in the macros).
14const MULTILINE_TAGS: &[&str] = &[
15    "description",
16    "details",
17    "return",
18    "returns",
19    "param",
20    "note",
21    "seealso",
22    "section",
23    "format",
24    "references",
25    "slot",
26    "field",
27    "value",
28    "prop",
29    "describeIn",
30    "family",
31    "inherit",
32    "inheritParams",
33    "inheritSection",
34    "source",
35    "author",
36];
37
38/// One line of doc text that roxygen2 reads as markdown.
39#[derive(#[automatically_derived]
impl ::core::clone::Clone for MarkdownLine {
    #[inline]
    fn clone(&self) -> Self {
        MarkdownLine {
            line: ::core::clone::Clone::clone(&self.line),
            text: ::core::clone::Clone::clone(&self.text),
            leading_prose: ::core::clone::Clone::clone(&self.leading_prose),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for MarkdownLine {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field3_finish(f, "MarkdownLine",
            "line", &self.line, "text", &self.text, "leading_prose",
            &&self.leading_prose)
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for MarkdownLine { }
#[automatically_derived]
impl ::core::cmp::PartialEq for MarkdownLine {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.leading_prose == other.leading_prose && self.line == other.line
            && self.text == other.text
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for MarkdownLine {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<usize>;
        let _: ::core::cmp::AssertParamIsEq<String>;
        let _: ::core::cmp::AssertParamIsEq<bool>;
    }
}Eq)]
40pub struct MarkdownLine {
41    /// 1-based source line.
42    pub line: usize,
43    /// The text, with the `@tag name` prefix removed on a tag's first line.
44    pub text: String,
45    /// Whether the line is leading prose (before the first `@tag`).
46    pub leading_prose: bool,
47}
48
49/// A `pkg::topic` link as roxygen2 reads it.
50#[derive(#[automatically_derived]
impl ::core::clone::Clone for PkgLink {
    #[inline]
    fn clone(&self) -> Self {
        PkgLink {
            line: ::core::clone::Clone::clone(&self.line),
            written: ::core::clone::Clone::clone(&self.written),
            pkg: ::core::clone::Clone::clone(&self.pkg),
            leading_prose: ::core::clone::Clone::clone(&self.leading_prose),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for PkgLink {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field4_finish(f, "PkgLink",
            "line", &self.line, "written", &self.written, "pkg", &self.pkg,
            "leading_prose", &&self.leading_prose)
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for PkgLink { }
#[automatically_derived]
impl ::core::cmp::PartialEq for PkgLink {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.leading_prose == other.leading_prose && self.line == other.line
                && self.written == other.written && self.pkg == other.pkg
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for PkgLink {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<usize>;
        let _: ::core::cmp::AssertParamIsEq<String>;
        let _: ::core::cmp::AssertParamIsEq<bool>;
    }
}Eq)]
51pub struct PkgLink {
52    /// 1-based source line.
53    pub line: usize,
54    /// The link as written, brackets included.
55    pub written: String,
56    /// Everything before the last `::` of the target.
57    pub pkg: String,
58    /// Whether it sits in leading prose.
59    pub leading_prose: bool,
60}
61
62/// Every `pkg::topic` link in the markdown text of `attrs`' doc comments.
63pub fn pkg_links(attrs: &[Attribute]) -> Vec<PkgLink> {
64    let mut links = Vec::new();
65    for md in markdown_lines(attrs) {
66        for (written, target) in bracketed_targets(&md.text) {
67            if let Some(pkg) = r_package_of(target) {
68                links.push(PkgLink {
69                    line: md.line,
70                    written: written.to_string(),
71                    pkg: pkg.to_string(),
72                    leading_prose: md.leading_prose,
73                });
74            }
75        }
76    }
77    links
78}
79
80/// Whether the doc comment of `attrs` has a `@tag` line for `tag`: its first
81/// word for a one-word `tag` (`noRd`), its whole text for a longer one
82/// (`keywords internal`), as `has_roxygen_tag` in the macros matches it.
83pub fn has_tag(attrs: &[Attribute], tag: &str) -> bool {
84    attrs.iter().any(|attr| {
85        let syn::Meta::NameValue(nv) = &attr.meta else {
86            return false;
87        };
88        let syn::Expr::Lit(syn::ExprLit {
89            lit: syn::Lit::Str(lit),
90            ..
91        }) = &nv.value
92        else {
93            return false;
94        };
95        attr.path().is_ident("doc")
96            && lit.value().lines().any(|line| {
97                line.trim_start().strip_prefix('@').is_some_and(|rest| {
98                    if tag.contains(' ') {
99                        rest.trim() == tag
100                    } else {
101                        rest.split_whitespace().next() == Some(tag)
102                    }
103                })
104            })
105    })
106}
107
108/// The doc text of `attrs` that roxygen2 reads as markdown.
109pub fn markdown_lines(attrs: &[Attribute]) -> Vec<MarkdownLine> {
110    let mut out = Vec::new();
111    // `None` is leading prose; `Some(true)` a tag whose text is markdown.
112    let mut section: Option<bool> = None;
113    let mut fence: Option<String> = None;
114    for attr in attrs {
115        if !attr.path().is_ident("doc") {
116            continue;
117        }
118        let syn::Meta::NameValue(nv) = &attr.meta else {
119            continue;
120        };
121        let syn::Expr::Lit(syn::ExprLit {
122            lit: syn::Lit::Str(lit),
123            ..
124        }) = &nv.value
125        else {
126            continue;
127        };
128        let first_line = attr.span().start().line;
129        for (offset, raw) in lit.value().lines().enumerate() {
130            let trimmed = raw.trim_start();
131            if let Some(open) = &fence {
132                if trimmed.starts_with(open.as_str()) {
133                    fence = None;
134                }
135                continue;
136            }
137            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
138                fence = Some(trimmed[..3].to_string());
139                continue;
140            }
141            let mut text = raw;
142            if let Some(rest) = trimmed.strip_prefix('@')
143                && rest.starts_with(|c: char| c.is_ascii_alphabetic())
144            {
145                let name_end = rest
146                    .find(|c: char| !c.is_ascii_alphanumeric())
147                    .unwrap_or(rest.len());
148                section = Some(MULTILINE_TAGS.contains(&&rest[..name_end]));
149                text = &rest[name_end..];
150            }
151            if section != Some(false) {
152                out.push(MarkdownLine {
153                    line: first_line + offset,
154                    text: text.to_string(),
155                    leading_prose: section.is_none(),
156                });
157            }
158        }
159    }
160    out
161}
162
163/// The `[...]` link targets in one line of markdown, as `(written, target)`.
164///
165/// Mirrors the scanner in `miniextendr-macros/src/roxygen.rs`
166/// (`sanitize_roxygen_links`): code spans and `\[` are skipped, an inline
167/// `[text](dest)` is no R link, `[text][target]` has its own target and
168/// `[target]` is its own.
169fn bracketed_targets(s: &str) -> Vec<(&str, &str)> {
170    let bytes = s.as_bytes();
171    let mut out = Vec::new();
172    let mut i = 0;
173    let mut in_code = false;
174    while i < s.len() {
175        match bytes[i] {
176            b'`' => in_code = !in_code,
177            b'[' if !in_code && !(i > 0 && bytes[i - 1] == b'\\') => {
178                if let Some(close_rel) = s[i + 1..].find(']') {
179                    let close = i + 1 + close_rel;
180                    let inner = &s[i + 1..close];
181                    match bytes.get(close + 1) {
182                        Some(b'(') => {}
183                        Some(b'[') => {
184                            if let Some(t_rel) = s[close + 2..].find(']') {
185                                let end = close + 2 + t_rel + 1;
186                                out.push((&s[i..end], &s[close + 2..end - 1]));
187                                i = end;
188                                continue;
189                            }
190                            out.push((&s[i..close + 1], inner));
191                        }
192                        _ => out.push((&s[i..close + 1], inner)),
193                    }
194                    i = close + 1;
195                    continue;
196                }
197            }
198            _ => {}
199        }
200        i += 1;
201    }
202    out
203}
204
205/// The R package roxygen2 links `target` into, when it reads it as `pkg::topic`
206/// for a package name R accepts and a Rust path root it does not own.
207fn r_package_of(target: &str) -> Option<&str> {
208    let target = target.trim();
209    let target = target
210        .strip_prefix('`')
211        .and_then(|t| t.strip_suffix('`'))
212        .unwrap_or(target);
213    if target.contains(char::is_whitespace) {
214        return None;
215    }
216    let (pkg, topic) = target.rsplit_once("::")?;
217    let topic = topic.strip_suffix("()").unwrap_or(topic);
218    let name_ok = pkg.len() >= 2
219        && pkg.starts_with(|c: char| c.is_ascii_alphabetic())
220        && !pkg.ends_with('.')
221        && pkg.chars().all(|c| c.is_ascii_alphanumeric() || c == '.');
222    let topic_ok = !topic.is_empty()
223        && topic
224            .chars()
225            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.');
226    (name_ok && topic_ok && !#[allow(non_exhaustive_omitted_patterns)] match pkg {
    "crate" | "self" | "Self" => true,
    _ => false,
}matches!(pkg, "crate" | "self" | "Self")).then_some(pkg)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn links(doc: &[&str]) -> Vec<(String, String, bool)> {
234        let attrs: Vec<Attribute> = doc.iter().map(|l| syn::parse_quote!(#[doc = #l])).collect();
235        pkg_links(&attrs)
236            .into_iter()
237            .map(|l| (l.written, l.pkg, l.leading_prose))
238            .collect()
239    }
240
241    #[test]
242    fn finds_every_link_form_and_skips_the_rest() {
243        let found = links(&[
244            " See [`Sources::prepare`] and [Type::method()].",
245            " [text][dplyr::bind_rows], [t][crate::x], [`a::b::c`], [x](y::z)",
246            " `[code::span]` and \\[esc::aped] and [plain]",
247        ]);
248        let pkgs: Vec<_> = found.iter().map(|f| f.1.as_str()).collect();
249        assert_eq!(pkgs, ["Sources", "Type", "dplyr"]);
250        assert!(found.iter().all(|f| f.2), "all leading prose");
251    }
252
253    #[test]
254    fn skips_code_tags_fences_and_lines_after_single_line_tags() {
255        let found = links(&[
256            " @details Use [aa::b] here",
257            " and [cc::d] there.",
258            " ```",
259            " [ee::f]",
260            " ```",
261            " @examples",
262            " [gg::h]",
263            " @export",
264            " Rust callers: [ii::j]",
265            " @param x see [kk::l]",
266        ]);
267        let pkgs: Vec<_> = found.iter().map(|f| f.1.as_str()).collect();
268        assert_eq!(pkgs, ["aa", "cc", "kk"]);
269        assert!(found.iter().all(|f| !f.2), "all in tags");
270    }
271}