use std::collections::HashMap;
use std::path::{Path, PathBuf};
use log::warn;
use serde::Serialize;
use unicode_normalization::UnicodeNormalization;
use crate::doctree::{AttrValue, Doctree, Node};
use crate::env::{BuildEnvironment, IndexEntryRecord};
use crate::error::{BuildWarning, WarningType};
use crate::rst::block::py_repr;
const INDEX: &str = "index";
const RTL_MARK: char = '\u{200f}';
const INDEX_CATEGORY: &str = "index";
pub fn process_doc(
env: &mut BuildEnvironment,
docname: &str,
doctree: &mut Doctree,
path: &Path,
warnings: &mut Vec<BuildWarning>,
) {
let mut collected: Vec<IndexEntryRecord> = Vec::new();
let sources = std::mem::take(&mut doctree.sources);
visit(
&mut doctree.root,
docname,
&mut collected,
&mut |message, node| {
let source_path = sources
.get(node.span.source as usize)
.map(PathBuf::from)
.unwrap_or_else(|| path.to_path_buf());
warnings.push(
BuildWarning::new(
source_path,
Some(node.span.line as usize),
message,
WarningType::Other,
)
.with_category(Some(INDEX_CATEGORY.to_string())),
);
},
);
doctree.sources = sources;
env.index_entries
.entry(docname.to_string())
.or_default()
.extend(collected);
}
fn visit<F: FnMut(String, &Node)>(
node: &mut Node,
docname: &str,
out: &mut Vec<IndexEntryRecord>,
warn: &mut F,
) {
let mut i = 0;
while i < node.children.len() {
if node.children[i].kind == INDEX {
let entries = index_node_entries(&node.children[i], docname);
match entries
.iter()
.try_for_each(|entry| split_index_msg(&entry.entry_type, &entry.value).map(|_| ()))
{
Ok(()) => out.extend(entries),
Err(message) => {
warn(message, &node.children[i]);
node.children.remove(i);
continue;
}
}
} else {
visit(&mut node.children[i], docname, out, warn);
}
i += 1;
}
}
fn index_node_entries(node: &Node, docname: &str) -> Vec<IndexEntryRecord> {
match node.get("entries") {
Some(AttrValue::List(items)) => parse_index_entries(items, docname),
Some(_) => {
warn!(
"{docname}: an `index` node's `entries` is not a list attribute, so its \
index entries were dropped. This is a doctree cached before the \
list-attribute format; delete the cache directory to re-read it."
);
Vec::new()
}
None => Vec::new(),
}
}
pub(crate) fn parse_index_entries(items: &[String], docname: &str) -> Vec<IndexEntryRecord> {
items
.iter()
.filter_map(|item| {
let parsed = parse_tuple(item);
if parsed.is_none() {
warn!("{docname}: unparsable `index` entry {item:?} was dropped");
}
parsed
})
.collect()
}
fn parse_tuple(item: &str) -> Option<IndexEntryRecord> {
let chars: Vec<char> = item.chars().collect();
let mut at = 0usize;
expect(&chars, &mut at, '(')?;
let mut fields: Vec<Option<String>> = Vec::with_capacity(5);
for field in 0..5 {
fields.push(parse_literal(&chars, &mut at)?);
if field < 4 {
expect(&chars, &mut at, ',')?;
while chars.get(at) == Some(&' ') {
at += 1;
}
}
}
expect(&chars, &mut at, ')')?;
if at != chars.len() {
return None;
}
let main = fields[3].clone()?;
Some(IndexEntryRecord {
entry_type: fields[0].clone()?,
value: fields[1].clone()?,
target_id: fields[2].clone()?,
main: !main.is_empty(),
category_key: fields[4].clone(),
})
}
fn expect(chars: &[char], at: &mut usize, want: char) -> Option<()> {
if chars.get(*at) == Some(&want) {
*at += 1;
Some(())
} else {
None
}
}
fn parse_literal(chars: &[char], at: &mut usize) -> Option<Option<String>> {
if chars[*at..].starts_with(&['N', 'o', 'n', 'e']) {
*at += 4;
return Some(None);
}
let quote = *chars.get(*at)?;
if quote != '\'' && quote != '"' {
return None;
}
*at += 1;
let mut out = String::new();
loop {
let c = *chars.get(*at)?;
*at += 1;
match c {
_ if c == quote => return Some(Some(out)),
'\\' => {
let escaped = *chars.get(*at)?;
*at += 1;
out.push(match escaped {
'n' => '\n',
'r' => '\r',
't' => '\t',
other => other,
});
}
other => out.push(other),
}
}
}
fn split_index_msg(entry_type: &str, value: &str) -> Result<Vec<String>, String> {
match entry_type {
"single" => split_into(2, "single", value).or_else(|_| split_into(1, "single", value)),
"pair" => split_into(2, "pair", value),
"triple" => split_into(3, "triple", value),
"see" | "seealso" => split_into(2, "see", value),
other => Err(invalid_entry(other, value)),
}
}
fn split_into(n: usize, entry_type: &str, value: &str) -> Result<Vec<String>, String> {
let parts: Vec<String> = value
.splitn(n, ';')
.map(|part| part.trim().to_string())
.collect();
if parts.iter().filter(|part| !part.is_empty()).count() < n {
return Err(invalid_entry(entry_type, value));
}
Ok(parts)
}
fn invalid_entry(entry_type: &str, value: &str) -> String {
format!("invalid {entry_type} index entry {}", py_repr(Some(value)))
}
pub type IndexTarget = (String, String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexGroup {
pub group: String,
pub entries: Vec<IndexEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexEntry {
pub name: String,
pub targets: Vec<IndexTarget>,
pub subitems: Vec<IndexSubItem>,
pub category_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexSubItem {
pub name: String,
pub targets: Vec<IndexTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexMessage {
pub docname: String,
pub message: String,
}
impl IndexMessage {
pub fn into_warning(self, path: &Path) -> BuildWarning {
BuildWarning::new(path.to_path_buf(), None, self.message, WarningType::Other)
.with_category(Some(INDEX_CATEGORY.to_string()))
}
}
pub fn create_index(
env: &BuildEnvironment,
rel_uri: &dyn Fn(&str) -> Option<String>,
messages: &mut Vec<IndexMessage>,
) -> Vec<IndexGroup> {
let mut new = Working::default();
for (docname, entries) in &env.index_entries {
let rel = rel_uri(docname);
for entry in entries {
let uri = rel.as_ref().map(|rel| format!("{rel}#{}", entry.target_id));
if let Err(message) = add(&mut new, entry, uri.as_deref()) {
messages.push(IndexMessage {
docname: docname.clone(),
message,
});
}
}
}
for bucket in &mut new.buckets {
bucket.targets.sort_by_cached_key(target_sort_key);
for (_, targets, _) in &mut bucket.sub_items {
targets.sort_by_cached_key(target_sort_key);
}
}
let mut new_list = new.buckets;
new_list
.sort_by_cached_key(|bucket| entry_sort_key(&bucket.key, bucket.category_key.as_deref()));
group_entries(&mut new_list);
let mut grouped: Vec<IndexGroup> = Vec::new();
for bucket in new_list {
let group = group_of(&bucket.key, bucket.category_key.as_deref());
let mut subitems: Vec<IndexSubItem> = bucket
.sub_items
.into_iter()
.map(|(name, targets, _)| IndexSubItem { name, targets })
.collect();
subitems.sort_by_cached_key(|item| sub_entry_sort_key(&item.name));
let entry = IndexEntry {
name: bucket.key,
targets: bucket.targets,
subitems,
category_key: bucket.category_key,
};
match grouped.last_mut() {
Some(last) if last.group == group => last.entries.push(entry),
_ => grouped.push(IndexGroup {
group,
entries: vec![entry],
}),
}
}
grouped
}
#[derive(Default)]
struct Working {
buckets: Vec<Bucket>,
index: HashMap<String, usize>,
}
struct Bucket {
key: String,
targets: Vec<IndexTarget>,
sub_items: Vec<(String, Vec<IndexTarget>, Option<String>)>,
sub_index: HashMap<String, usize>,
category_key: Option<String>,
}
impl Working {
fn add_entry(
&mut self,
word: &str,
subword: &str,
main: Option<&str>,
link: Option<&str>,
key: Option<&str>,
) {
let at = match self.index.get(word) {
Some(&at) => at,
None => {
let at = self.buckets.len();
self.buckets.push(Bucket {
key: word.to_string(),
targets: Vec::new(),
sub_items: Vec::new(),
sub_index: HashMap::new(),
category_key: key.map(str::to_string),
});
self.index.insert(word.to_string(), at);
at
}
};
let bucket = &mut self.buckets[at];
let targets = if subword.is_empty() {
&mut bucket.targets
} else {
let sub_at = sub_item_slot(bucket, subword, key);
&mut bucket.sub_items[sub_at].1
};
if let Some(link) = link.filter(|link| !link.is_empty()) {
targets.push((main.unwrap_or_default().to_string(), link.to_string()));
}
}
}
fn sub_item_slot(bucket: &mut Bucket, subword: &str, key: Option<&str>) -> usize {
match bucket.sub_index.get(subword) {
Some(&at) => at,
None => {
let at = bucket.sub_items.len();
bucket
.sub_items
.push((subword.to_string(), Vec::new(), key.map(str::to_string)));
bucket.sub_index.insert(subword.to_string(), at);
at
}
}
}
fn add(new: &mut Working, entry: &IndexEntryRecord, uri: Option<&str>) -> Result<(), String> {
let main = if entry.main { "main" } else { "" };
let key = entry.category_key.as_deref();
match entry.entry_type.as_str() {
"single" => {
let (word, subword) = match split_into(2, "single", &entry.value) {
Ok(parts) => (parts[0].clone(), parts[1].clone()),
Err(_) => (
split_into(1, "single", &entry.value)?.remove(0),
String::new(),
),
};
new.add_entry(&word, &subword, Some(main), uri, key);
}
"pair" => {
let parts = split_into(2, "pair", &entry.value)?;
new.add_entry(&parts[0], &parts[1], Some(main), uri, key);
new.add_entry(&parts[1], &parts[0], Some(main), uri, key);
}
"triple" => {
let parts = split_into(3, "triple", &entry.value)?;
let (first, second, third) = (&parts[0], &parts[1], &parts[2]);
new.add_entry(first, &format!("{second} {third}"), Some(main), uri, key);
new.add_entry(second, &format!("{third}, {first}"), Some(main), uri, key);
new.add_entry(third, &format!("{first} {second}"), Some(main), uri, key);
}
"see" => {
let parts = split_into(2, "see", &entry.value)?;
new.add_entry(&parts[0], &format!("see {}", parts[1]), None, None, key);
}
"seealso" => {
let parts = split_into(2, "see", &entry.value)?;
new.add_entry(
&parts[0],
&format!("see also {}", parts[1]),
None,
None,
key,
);
}
other => return Err(format!("unknown index entry type {}", py_repr(Some(other)))),
}
Ok(())
}
fn target_sort_key(target: &IndexTarget) -> (bool, String) {
(target.0.is_empty(), target.1.clone())
}
fn entry_sort_key(key: &str, category_key: Option<&str>) -> (u8, String, String) {
let folded = match category_key {
Some(category) if !category.is_empty() => category,
_ => key,
};
let lc_key = fold(folded);
let first = lc_key.chars().next();
let group = if !first.is_some_and(py_isalpha) && !lc_key.starts_with('_') {
0 } else {
1
};
(group, lc_key, key.to_string())
}
fn sub_entry_sort_key(name: &str) -> String {
let key = fold(name);
if key.chars().next().is_some_and(py_isalpha) || key.starts_with('_') {
format!("\u{7f}{key}")
} else {
key
}
}
fn group_of(key: &str, category_key: Option<&str>) -> String {
if let Some(category) = category_key {
return category.to_string();
}
let key = key.strip_prefix(RTL_MARK).unwrap_or(key);
let Some(first) = key.chars().next() else {
return "Symbols".to_string();
};
let Some(decomposed) = first.nfd().next() else {
return "Symbols".to_string();
};
let letter: String = decomposed.to_uppercase().collect();
if (!letter.is_empty() && letter.chars().all(py_isalpha)) || letter == "_" {
letter
} else {
"Symbols".to_string()
}
}
fn fold(key: &str) -> String {
let lowered: String = key.to_lowercase().nfd().collect();
match lowered.strip_prefix(RTL_MARK) {
Some(rest) => rest.to_string(),
None => lowered,
}
}
fn py_isalpha(c: char) -> bool {
c.is_alphabetic()
}
fn group_entries(new_list: &mut Vec<Bucket>) {
let mut old_key = String::new();
let mut old_index: Option<usize> = None;
let mut i = 0;
while i < new_list.len() {
if new_list[i].sub_items.is_empty() {
match fixre_match(&new_list[i].key) {
Some((prefix, parenthesized)) => {
if old_key == prefix {
let moved = new_list.remove(i);
if let Some(old) = old_index {
let bucket = &mut new_list[old];
let at = sub_item_slot(
bucket,
&parenthesized,
moved.category_key.as_deref(),
);
bucket.sub_items[at].1.extend(moved.targets);
}
continue;
}
old_key = prefix;
}
None => old_key = new_list[i].key.clone(),
}
}
old_index = Some(i);
i += 1;
}
}
fn fixre_match(key: &str) -> Option<(String, String)> {
let limit = key.find('\n').unwrap_or(key.len());
let bytes = key.as_bytes();
for open in (0..limit.saturating_sub(1)).rev() {
if bytes[open] != b' ' || bytes[open + 1] != b'(' {
continue;
}
let rest = &key[open + 2..];
let Some(close) = rest.find(['(', ')']) else {
continue;
};
if rest.as_bytes()[close] != b')' {
continue;
}
return Some((
key[..open].to_string(),
key[open + 1..open + 3 + close].to_string(),
));
}
None
}
pub fn snapshot(groups: &[IndexGroup]) -> serde_json::Value {
serde_json::to_value(groups).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rst::block::index_entry_tuple;
fn record(
entry_type: &str,
value: &str,
target_id: &str,
main: bool,
category_key: Option<&str>,
) -> IndexEntryRecord {
IndexEntryRecord {
entry_type: entry_type.to_string(),
value: value.to_string(),
target_id: target_id.to_string(),
main,
category_key: category_key.map(str::to_string),
}
}
fn index_of(entries: &[(&str, Vec<IndexEntryRecord>)]) -> (Vec<IndexGroup>, Vec<IndexMessage>) {
let mut env = BuildEnvironment::default();
for (docname, records) in entries {
env.index_entries
.insert((*docname).to_string(), records.clone());
}
let mut messages = Vec::new();
let groups = create_index(&env, &|_| Some(String::new()), &mut messages);
(groups, messages)
}
#[test]
fn every_rendered_tuple_parses_back_to_the_entry_it_came_from() {
let cases = [
("single", "Alpha", "index-0", "", None),
("pair", "bread; butter", "index-1", "main", None),
("single", "it's a term", "id", "", Some("K")),
("single", "say \"hi\"", "id", "", None),
("single", "both ' and \"", "id", "", None),
("single", "back\\slash", "id", "", None),
("single", "back\\ slash", "id", "", None),
("single", "tab\there", "id", "", None),
("single", "new\nline", "id", "", None),
("single", "carriage\rreturn", "id", "", None),
("single", "", "id", "", Some("")),
("single", "(None, 'not a tuple')", "id", "", None),
];
for (entry_type, value, target_id, main, key) in cases {
let rendered = index_entry_tuple(entry_type, value, target_id, main, key);
let parsed = parse_index_entries(std::slice::from_ref(&rendered), "a");
assert_eq!(
parsed,
vec![record(entry_type, value, target_id, !main.is_empty(), key)],
"round trip failed for {rendered:?}"
);
}
}
#[test]
fn a_stale_string_shaped_entries_attribute_harvests_nothing() {
let mut index = Node::elem(INDEX, crate::doctree::Span::ZERO);
index.set(
"entries",
AttrValue::Str("('single',\\ 'Alpha',\\ 'index-0',\\ '',\\ None)".to_string()),
);
let mut root = Node::elem(crate::doctree::kinds::DOCUMENT, crate::doctree::Span::ZERO);
root.children.push(index);
let mut doctree = Doctree {
root,
sources: vec!["<document>".to_string()],
};
let mut env = BuildEnvironment::default();
let mut warnings = Vec::new();
process_doc(
&mut env,
"a",
&mut doctree,
Path::new("a.rst"),
&mut warnings,
);
assert_eq!(env.index_entries["a"], Vec::new());
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(doctree.root.children.len(), 1);
}
#[test]
fn a_malformed_entries_item_is_dropped_not_panicked_on() {
assert!(parse_index_entries(&["('single', 'x'".to_string()], "a").is_empty());
assert!(
parse_index_entries(&["('a', 'b', 'c', 'd', 'e', 'f')".to_string()], "a").is_empty()
);
assert!(parse_index_entries(&[String::new()], "a").is_empty());
}
#[test]
fn split_index_msg_texts_match_sphinx() {
assert_eq!(
split_index_msg("pair", "lonely"),
Err("invalid pair index entry 'lonely'".to_string())
);
assert_eq!(
split_index_msg("pair", "a; "),
Err("invalid pair index entry 'a; '".to_string())
);
assert_eq!(
split_index_msg("triple", "a; b"),
Err("invalid triple index entry 'a; b'".to_string())
);
assert_eq!(
split_index_msg("seealso", "lonely"),
Err("invalid see index entry 'lonely'".to_string())
);
assert_eq!(
split_index_msg("bogus", "x"),
Err("invalid bogus index entry 'x'".to_string())
);
assert_eq!(
split_index_msg("single", "Alpha"),
Ok(vec!["Alpha".to_string()])
);
assert_eq!(
split_index_msg("single", "Alpha; Beta"),
Ok(vec!["Alpha".to_string(), "Beta".to_string()])
);
assert_eq!(
split_index_msg("pair", "a; b; c"),
Ok(vec!["a".to_string(), "b; c".to_string()])
);
}
#[test]
fn an_unknown_entry_type_warns_with_the_sphinx_text() {
let (groups, messages) = index_of(&[("a", vec![record("bogus", "x", "id", false, None)])]);
assert!(groups.is_empty());
assert_eq!(
messages,
vec![IndexMessage {
docname: "a".to_string(),
message: "unknown index entry type 'bogus'".to_string(),
}]
);
}
#[test]
fn an_invalid_value_warns_with_the_value_error_text() {
let (_, messages) = index_of(&[("a", vec![record("pair", "lonely", "id", false, None)])]);
assert_eq!(
messages,
vec![IndexMessage {
docname: "a".to_string(),
message: "invalid pair index entry 'lonely'".to_string(),
}]
);
}
#[test]
fn a_document_with_no_uri_contributes_a_linkless_entry() {
let mut env = BuildEnvironment::default();
env.index_entries.insert(
"a".to_string(),
vec![record("single", "Alpha", "id", false, None)],
);
let groups = create_index(&env, &|_| None, &mut Vec::new());
assert_eq!(groups.len(), 1);
assert!(groups[0].entries[0].targets.is_empty());
}
#[test]
fn main_targets_come_first() {
let (groups, _) = index_of(&[(
"a",
vec![
record("single", "Alpha", "z", false, None),
record("single", "Alpha", "b", true, None),
record("single", "Alpha", "a", false, None),
],
)]);
assert_eq!(
groups[0].entries[0].targets,
vec![
("main".to_string(), "#b".to_string()),
(String::new(), "#a".to_string()),
(String::new(), "#z".to_string()),
]
);
}
#[test]
fn consecutive_parenthesized_entries_collapse_into_subitems() {
let (groups, _) = index_of(&[(
"a",
vec![
record("single", "func() (in module foo)", "f1", false, None),
record("single", "func() (in module bar)", "f2", false, None),
],
)]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].entries.len(), 1);
let entry = &groups[0].entries[0];
assert_eq!(entry.name, "func() (in module bar)");
assert_eq!(
entry
.subitems
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
vec!["(in module foo)"]
);
}
#[test]
fn an_entry_with_subitems_is_never_collapsed() {
let (groups, _) = index_of(&[(
"a",
vec![
record("pair", "func() (in module foo); detail", "f1", false, None),
record("single", "func() (in module goo)", "f2", false, None),
],
)]);
let names: Vec<&str> = groups
.iter()
.flat_map(|group| group.entries.iter().map(|entry| entry.name.as_str()))
.collect();
assert!(
names.contains(&"func() (in module foo)") && names.contains(&"func() (in module goo)"),
"{names:?}"
);
}
#[test]
fn sub_entries_that_fold_alike_keep_their_insertion_order() {
let (groups, _) = index_of(&[(
"a",
vec![
record("pair", "word; Beta", "b1", false, None),
record("pair", "word; beta", "b2", false, None),
],
)]);
let word = groups
.iter()
.flat_map(|group| &group.entries)
.find(|entry| entry.name == "word")
.expect("the `word` entry");
assert_eq!(
word.subitems
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
vec!["Beta", "beta"]
);
}
#[test]
fn fixre_takes_the_rightmost_parenthesized_group() {
assert_eq!(
fixre_match("a (b) (c)"),
Some(("a (b)".to_string(), "(c)".to_string()))
);
assert_eq!(
fixre_match("a (b) c"),
Some(("a".to_string(), "(b)".to_string()))
);
assert_eq!(fixre_match("plain"), None);
assert_eq!(
fixre_match("nested (a (b))"),
Some(("nested (a".to_string(), "(b)".to_string()))
);
assert_eq!(
fixre_match("func() (in module foo)"),
Some(("func()".to_string(), "(in module foo)".to_string()))
);
assert_eq!(fixre_match("a\nb (c)"), None);
}
#[test]
fn a_category_key_overrides_the_group_and_the_sort_key() {
let (groups, _) = index_of(&[(
"a",
vec![
record("single", "zebra", "z", false, Some("A")),
record("single", "apple", "a", false, None),
],
)]);
assert_eq!(
groups
.iter()
.map(|group| group.group.as_str())
.collect::<Vec<_>>(),
vec!["A"]
);
assert_eq!(groups[0].entries[0].name, "zebra");
assert_eq!(groups[0].entries[1].name, "apple");
}
#[test]
fn grouping_follows_the_first_decomposed_character() {
assert_eq!(group_of("42answer", None), "Symbols");
assert_eq!(group_of("_private", None), "_");
assert_eq!(group_of("alpha", None), "A");
assert_eq!(group_of("Ábc", None), "A");
assert_eq!(group_of("--flag", None), "Symbols");
assert_eq!(group_of("x", Some("")), "");
}
}