1use syn::Attribute;
11use syn::spanned::Spanned;
12
13const 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#[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 pub line: usize,
43 pub text: String,
45 pub leading_prose: bool,
47}
48
49#[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 pub line: usize,
54 pub written: String,
56 pub pkg: String,
58 pub leading_prose: bool,
60}
61
62pub 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
80pub 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
108pub fn markdown_lines(attrs: &[Attribute]) -> Vec<MarkdownLine> {
110 let mut out = Vec::new();
111 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
163fn 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
205fn 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}