use pulldown_cmark::{
BrokenLink, BrokenLinkCallback, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd,
};
use serde_json::{Map, Value};
use std::ops::Range;
pub(super) fn unlink_rustdoc_descriptions(map: &mut Map<String, Value>) {
for (key, value) in map.iter_mut() {
match (key.as_str(), value) {
("description", Value::String(text)) => {
if let Some(unlinked) = unlink_rustdoc(text) {
*text = unlinked;
}
}
(keyword, _) if INSTANCE_KEYWORDS.contains(&keyword) => {}
(keyword, Value::Object(named)) if NAMED_SCHEMA_MAPS.contains(&keyword) => {
named.values_mut().for_each(unlink_in_value);
}
(_, value) => unlink_in_value(value),
}
}
}
fn unlink_in_value(value: &mut Value) {
match value {
Value::Object(map) => unlink_rustdoc_descriptions(map),
Value::Array(items) => items.iter_mut().for_each(unlink_in_value),
_ => {}
}
}
pub(super) const INSTANCE_KEYWORDS: [&str; 5] = ["default", "examples", "example", "const", "enum"];
pub(super) const NAMED_SCHEMA_MAPS: [&str; 6] = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
];
const DISAMBIGUATORS: [&str; 20] = [
"struct",
"enum",
"trait",
"union",
"mod",
"module",
"const",
"constant",
"static",
"fn",
"function",
"method",
"derive",
"field",
"variant",
"type",
"value",
"macro",
"prim",
"primitive",
];
const CALL_SUFFIXES: [&str; 4] = ["!()", "!{}", "()", "!"];
const MARKDOWN: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_SMART_PUNCTUATION);
pub(super) fn unlink_rustdoc(text: &str) -> Option<String> {
let parser = parse(text).into_offset_iter();
let definitions: Vec<Range<usize>> = parser
.reference_definitions()
.iter()
.filter(|(_, definition)| is_rustdoc_target(&definition.dest))
.map(|(_, definition)| definition.span.clone())
.collect();
let events: Vec<(Event<'_>, Range<usize>)> = parser.collect();
let mut edits: Vec<(Range<usize>, String)> = Vec::new();
let mut expected = Vec::with_capacity(events.len());
let mut at = 0;
while let Some((event, range)) = events.get(at) {
match event {
Event::Start(Tag::Link {
link_type,
dest_url,
..
}) if is_rustdoc_link(*link_type, dest_url) => {
let end = at
+ events[at..]
.iter()
.position(|(event, _)| matches!(event, Event::End(TagEnd::Link)))?;
let (shown, inner) = link_text(text, *link_type, &events[at + 1..end]);
edits.push((link_source(text, *link_type, range), shown));
expected.extend(inner);
at = end + 1;
}
_ => {
expected.push(event.clone());
at += 1;
}
}
}
let removals: [DefinitionRemoval; 2] = [with_its_line, |_, span| span.clone()];
removals.iter().find_map(|removal| {
let mut all = edits.clone();
all.extend(
definitions
.iter()
.map(|span| (removal(text, span), String::new())),
);
let rewritten = apply(text, all)?;
reads_as(&rewritten, expected.clone()).then_some(rewritten)
})
}
fn reads_as(rewritten: &str, expected: Vec<Event<'_>>) -> bool {
let parser = parse(rewritten);
let defines_a_path = parser
.reference_definitions()
.iter()
.any(|(_, definition)| is_rustdoc_target(&definition.dest));
!defines_a_path && merged_text(expected) == merged_text(parser)
}
fn link_source(text: &str, link_type: LinkType, range: &Range<usize>) -> Range<usize> {
const COLLAPSED: &str = "[]";
let collapsed = matches!(link_type, LinkType::Collapsed | LinkType::CollapsedUnknown)
&& text[range.end..].starts_with(COLLAPSED);
range.start..range.end + if collapsed { COLLAPSED.len() } else { 0 }
}
type DefinitionRemoval = fn(&str, &Range<usize>) -> Range<usize>;
fn with_its_line(text: &str, span: &Range<usize>) -> Range<usize> {
let line_start = text[..span.start]
.rfind(LINE_ENDINGS)
.map_or(0, |ending| ending + 1);
let only_quote_markers = text[line_start..span.start]
.chars()
.all(|c| c == '>' || c == ' ' || c == '\t');
let start = if only_quote_markers {
line_start
} else {
span.start
};
let rest = &text[span.end..];
let ending = ["\r\n", "\n", "\r"]
.iter()
.find(|ending| rest.starts_with(**ending))
.map_or(0, |ending| ending.len());
start..span.end + ending
}
const LINE_ENDINGS: [char; 2] = ['\n', '\r'];
fn parse(text: &str) -> Parser<'_, impl BrokenLinkCallback<'_>> {
Parser::new_with_broken_link_callback(text, MARKDOWN, Some(resolve_rustdoc_reference))
}
fn resolve_rustdoc_reference(link: BrokenLink<'_>) -> Option<(CowStr<'_>, CowStr<'_>)> {
is_rustdoc_target(&link.reference).then_some((link.reference, CowStr::Borrowed("")))
}
fn is_rustdoc_link(link_type: LinkType, destination: &str) -> bool {
match link_type {
LinkType::Inline | LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut => {
is_rustdoc_target(destination)
}
LinkType::ReferenceUnknown | LinkType::CollapsedUnknown | LinkType::ShortcutUnknown => true,
_ => false,
}
}
fn is_rustdoc_target(target: &str) -> bool {
if target.contains('/') {
return false;
}
let target = target.replace('`', "");
let target = target.trim();
let item = target.split_once('#').map_or(target, |(item, _)| item);
let path = without_disambiguator(item).unwrap_or(item);
let path = CALL_SUFFIXES
.iter()
.find_map(|suffix| path.strip_suffix(suffix))
.unwrap_or(path);
path.chars()
.all(|c| c.is_alphanumeric() || ":_<>, !*&;".contains(c))
&& without_generics(path).is_some_and(|path| {
path.chars().any(|c| c.is_alphabetic() || c == '_')
&& path
.chars()
.all(|c| c.is_alphanumeric() || "_:&*;".contains(c))
&& !path.replace("::", "").contains(':')
})
}
fn without_generics(path: &str) -> Option<String> {
let mut depth = 0_usize;
let mut outside = String::with_capacity(path.len());
for c in path.chars() {
match c {
'<' => depth += 1,
'>' => depth = depth.checked_sub(1)?,
_ if depth == 0 => outside.push(c),
_ => {}
}
}
(depth == 0).then_some(outside)
}
fn without_disambiguator(target: &str) -> Option<&str> {
let (kind, path) = target.split_once('@')?;
DISAMBIGUATORS
.contains(&kind.trim())
.then(|| path.trim_start())
}
fn link_text<'a>(
text: &str,
link_type: LinkType,
inner: &[(Event<'a>, Range<usize>)],
) -> (String, Vec<Event<'a>>) {
let start = inner.iter().map(|(_, range)| range.start).min();
let end = inner.iter().map(|(_, range)| range.end).max();
let source = start.zip(end).map_or("", |(start, end)| &text[start..end]);
let names_its_target = matches!(
link_type,
LinkType::Shortcut
| LinkType::ShortcutUnknown
| LinkType::Collapsed
| LinkType::CollapsedUnknown
);
if names_its_target {
if let [(Event::Code(code), _)] = inner {
let path = shown_path(code);
if path != &**code {
let shown = source.replacen(&**code, path, 1);
return (shown, vec![Event::Code(CowStr::from(path.to_owned()))]);
}
}
let label = merged_text(inner.iter().map(|(event, _)| event.clone()));
if let [Event::Text(label)] = label.as_slice() {
let path = shown_path(label);
if path != label.trim() {
let shown = path.to_owned();
return (shown.clone(), vec![Event::Text(CowStr::from(shown))]);
}
}
}
let events = inner.iter().map(|(event, _)| event.clone()).collect();
(source.to_owned(), events)
}
fn shown_path(label: &str) -> &str {
let label = label.trim();
let item = label.split_once('#').map_or(label, |(item, _)| item);
without_disambiguator(item).unwrap_or(item)
}
fn apply(text: &str, mut edits: Vec<(Range<usize>, String)>) -> Option<String> {
if edits.is_empty() {
return None;
}
edits.sort_by_key(|(range, _)| range.start);
let mut rewritten = String::with_capacity(text.len());
let mut cursor = 0;
for (range, with) in edits {
rewritten.push_str(text.get(cursor..range.start)?);
rewritten.push_str(&with);
cursor = range.end;
}
rewritten.push_str(&text[cursor..]);
Some(rewritten)
}
fn merged_text<'a>(events: impl IntoIterator<Item = Event<'a>>) -> Vec<Event<'a>> {
let mut merged: Vec<Event<'a>> = Vec::new();
for event in events {
if let (Some(Event::Text(before)), Event::Text(after)) = (merged.last_mut(), &event) {
*before = CowStr::from(format!("{before}{after}"));
continue;
}
merged.push(event);
}
merged
}
pub(super) fn widen_id_properties(map: &mut Map<String, Value>, keys: &[&str]) {
if let Some(Value::Object(properties)) = map.get_mut("properties") {
for (name, subschema) in properties.iter_mut() {
if keys.contains(&name.as_str()) {
widen_id_schema(subschema);
}
}
}
for value in map.values_mut() {
widen_in_value(value, keys);
}
}
fn widen_in_value(value: &mut Value, keys: &[&str]) {
match value {
Value::Object(map) => widen_id_properties(map, keys),
Value::Array(items) => items.iter_mut().for_each(|item| widen_in_value(item, keys)),
_ => {}
}
}
fn widen_id_schema(schema: &mut Value) {
let Value::Object(map) = schema else {
return;
};
match map.get("type").cloned() {
Some(Value::String(kind)) if kind == "integer" => {
map.insert(
"type".to_owned(),
Value::Array(vec![
Value::String("integer".to_owned()),
Value::String("string".to_owned()),
]),
);
}
Some(Value::String(kind)) if kind == "array" => {
if let Some(items) = map.get_mut("items") {
widen_id_schema(items);
}
}
Some(Value::Array(mut kinds)) => {
let has_integer = kinds.iter().any(|kind| kind == "integer");
let has_string = kinds.iter().any(|kind| kind == "string");
if has_integer && !has_string {
let after = kinds
.iter()
.position(|kind| kind == "integer")
.map_or(kinds.len(), |position| position + 1);
kinds.insert(after, Value::String("string".to_owned()));
map.insert("type".to_owned(), Value::Array(kinds));
}
}
_ => {}
}
}