1use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47use log::warn;
48use serde::Serialize;
49use unicode_normalization::UnicodeNormalization;
50
51use crate::doctree::{AttrValue, Doctree, Node};
52use crate::env::{BuildEnvironment, IndexEntryRecord};
53use crate::error::{BuildWarning, WarningType};
54use crate::rst::block::py_repr;
55
56const INDEX: &str = "index";
58
59const RTL_MARK: char = '\u{200f}';
61
62const INDEX_CATEGORY: &str = "index";
65
66pub fn process_doc(
82 env: &mut BuildEnvironment,
83 docname: &str,
84 doctree: &mut Doctree,
85 path: &Path,
86 warnings: &mut Vec<BuildWarning>,
87) {
88 let mut collected: Vec<IndexEntryRecord> = Vec::new();
89 let sources = std::mem::take(&mut doctree.sources);
92 visit(
93 &mut doctree.root,
94 docname,
95 &mut collected,
96 &mut |message, node| {
97 let source_path = sources
98 .get(node.span.source as usize)
99 .map(PathBuf::from)
100 .unwrap_or_else(|| path.to_path_buf());
101 warnings.push(
102 BuildWarning::new(
103 source_path,
104 Some(node.span.line as usize),
105 message,
106 WarningType::Other,
107 )
108 .with_category(Some(INDEX_CATEGORY.to_string())),
109 );
110 },
111 );
112 doctree.sources = sources;
113 env.index_entries
114 .entry(docname.to_string())
115 .or_default()
116 .extend(collected);
117}
118
119fn visit<F: FnMut(String, &Node)>(
122 node: &mut Node,
123 docname: &str,
124 out: &mut Vec<IndexEntryRecord>,
125 warn: &mut F,
126) {
127 let mut i = 0;
128 while i < node.children.len() {
129 if node.children[i].kind == INDEX {
130 let entries = index_node_entries(&node.children[i], docname);
131 match entries
132 .iter()
133 .try_for_each(|entry| split_index_msg(&entry.entry_type, &entry.value).map(|_| ()))
134 {
135 Ok(()) => out.extend(entries),
136 Err(message) => {
137 warn(message, &node.children[i]);
138 node.children.remove(i);
139 continue;
140 }
141 }
142 } else {
143 visit(&mut node.children[i], docname, out, warn);
144 }
145 i += 1;
146 }
147}
148
149fn index_node_entries(node: &Node, docname: &str) -> Vec<IndexEntryRecord> {
157 match node.get("entries") {
158 Some(AttrValue::List(items)) => parse_index_entries(items, docname),
159 Some(_) => {
165 warn!(
166 "{docname}: an `index` node's `entries` is not a list attribute, so its \
167 index entries were dropped. This is a doctree cached before the \
168 list-attribute format; delete the cache directory to re-read it."
169 );
170 Vec::new()
171 }
172 None => Vec::new(),
173 }
174}
175
176pub(crate) fn parse_index_entries(items: &[String], docname: &str) -> Vec<IndexEntryRecord> {
186 items
187 .iter()
188 .filter_map(|item| {
189 let parsed = parse_tuple(item);
190 if parsed.is_none() {
191 warn!("{docname}: unparsable `index` entry {item:?} was dropped");
192 }
193 parsed
194 })
195 .collect()
196}
197
198fn parse_tuple(item: &str) -> Option<IndexEntryRecord> {
200 let chars: Vec<char> = item.chars().collect();
201 let mut at = 0usize;
202 expect(&chars, &mut at, '(')?;
203 let mut fields: Vec<Option<String>> = Vec::with_capacity(5);
204 for field in 0..5 {
205 fields.push(parse_literal(&chars, &mut at)?);
206 if field < 4 {
207 expect(&chars, &mut at, ',')?;
208 while chars.get(at) == Some(&' ') {
209 at += 1;
210 }
211 }
212 }
213 expect(&chars, &mut at, ')')?;
214 if at != chars.len() {
215 return None;
216 }
217 let main = fields[3].clone()?;
218 Some(IndexEntryRecord {
219 entry_type: fields[0].clone()?,
220 value: fields[1].clone()?,
221 target_id: fields[2].clone()?,
222 main: !main.is_empty(),
225 category_key: fields[4].clone(),
226 })
227}
228
229fn expect(chars: &[char], at: &mut usize, want: char) -> Option<()> {
230 if chars.get(*at) == Some(&want) {
231 *at += 1;
232 Some(())
233 } else {
234 None
235 }
236}
237
238fn parse_literal(chars: &[char], at: &mut usize) -> Option<Option<String>> {
240 if chars[*at..].starts_with(&['N', 'o', 'n', 'e']) {
241 *at += 4;
242 return Some(None);
243 }
244 let quote = *chars.get(*at)?;
245 if quote != '\'' && quote != '"' {
246 return None;
247 }
248 *at += 1;
249 let mut out = String::new();
250 loop {
251 let c = *chars.get(*at)?;
252 *at += 1;
253 match c {
254 _ if c == quote => return Some(Some(out)),
255 '\\' => {
256 let escaped = *chars.get(*at)?;
257 *at += 1;
258 out.push(match escaped {
259 'n' => '\n',
260 'r' => '\r',
261 't' => '\t',
262 other => other,
263 });
264 }
265 other => out.push(other),
266 }
267 }
268}
269
270pub(crate) fn split_index_msg(entry_type: &str, value: &str) -> Result<Vec<String>, String> {
273 match entry_type {
274 "single" => split_into(2, "single", value).or_else(|_| split_into(1, "single", value)),
275 "pair" => split_into(2, "pair", value),
276 "triple" => split_into(3, "triple", value),
277 "see" | "seealso" => split_into(2, "see", value),
278 other => Err(invalid_entry(other, value)),
279 }
280}
281
282fn split_into(n: usize, entry_type: &str, value: &str) -> Result<Vec<String>, String> {
291 let parts: Vec<String> = value
292 .splitn(n, ';')
293 .map(|part| part.trim().to_string())
294 .collect();
295 if parts.iter().filter(|part| !part.is_empty()).count() < n {
296 return Err(invalid_entry(entry_type, value));
297 }
298 Ok(parts)
299}
300
301fn invalid_entry(entry_type: &str, value: &str) -> String {
302 format!("invalid {entry_type} index entry {}", py_repr(Some(value)))
303}
304
305pub type IndexTarget = (String, String);
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
314pub struct IndexGroup {
315 pub group: String,
316 pub entries: Vec<IndexEntry>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
321pub struct IndexEntry {
322 pub name: String,
323 pub targets: Vec<IndexTarget>,
324 pub subitems: Vec<IndexSubItem>,
325 pub category_key: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct IndexSubItem {
330 pub name: String,
331 pub targets: Vec<IndexTarget>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct IndexMessage {
338 pub docname: String,
339 pub message: String,
340}
341
342impl IndexMessage {
343 pub fn into_warning(self, path: &Path) -> BuildWarning {
345 BuildWarning::new(path.to_path_buf(), None, self.message, WarningType::Other)
346 .with_category(Some(INDEX_CATEGORY.to_string()))
347 }
348}
349
350pub fn create_index(
356 env: &BuildEnvironment,
357 rel_uri: &dyn Fn(&str) -> Option<String>,
358 messages: &mut Vec<IndexMessage>,
359) -> Vec<IndexGroup> {
360 let mut new = Working::default();
361
362 for (docname, entries) in &env.index_entries {
376 let rel = rel_uri(docname);
377 for entry in entries {
378 let uri = rel.as_ref().map(|rel| format!("{rel}#{}", entry.target_id));
379 if let Err(message) = add(&mut new, entry, uri.as_deref()) {
380 messages.push(IndexMessage {
381 docname: docname.clone(),
382 message,
383 });
384 }
385 }
386 }
387
388 for bucket in &mut new.buckets {
389 bucket.targets.sort_by_cached_key(target_sort_key);
390 for (_, targets, _) in &mut bucket.sub_items {
391 targets.sort_by_cached_key(target_sort_key);
392 }
393 }
394
395 let mut new_list = new.buckets;
396 new_list
397 .sort_by_cached_key(|bucket| entry_sort_key(&bucket.key, bucket.category_key.as_deref()));
398
399 group_entries(&mut new_list);
400
401 let mut grouped: Vec<IndexGroup> = Vec::new();
402 for bucket in new_list {
403 let group = group_of(&bucket.key, bucket.category_key.as_deref());
404 let mut subitems: Vec<IndexSubItem> = bucket
405 .sub_items
406 .into_iter()
407 .map(|(name, targets, _)| IndexSubItem { name, targets })
408 .collect();
409 subitems.sort_by_cached_key(|item| sub_entry_sort_key(&item.name));
410 let entry = IndexEntry {
411 name: bucket.key,
412 targets: bucket.targets,
413 subitems,
414 category_key: bucket.category_key,
415 };
416 match grouped.last_mut() {
417 Some(last) if last.group == group => last.entries.push(entry),
418 _ => grouped.push(IndexGroup {
419 group,
420 entries: vec![entry],
421 }),
422 }
423 }
424 grouped
425}
426
427#[derive(Default)]
431struct Working {
432 buckets: Vec<Bucket>,
433 index: HashMap<String, usize>,
434}
435
436struct Bucket {
437 key: String,
438 targets: Vec<IndexTarget>,
439 sub_items: Vec<(String, Vec<IndexTarget>, Option<String>)>,
441 sub_index: HashMap<String, usize>,
442 category_key: Option<String>,
443}
444
445impl Working {
446 fn add_entry(
449 &mut self,
450 word: &str,
451 subword: &str,
452 main: Option<&str>,
453 link: Option<&str>,
454 key: Option<&str>,
455 ) {
456 let at = match self.index.get(word) {
457 Some(&at) => at,
458 None => {
459 let at = self.buckets.len();
460 self.buckets.push(Bucket {
461 key: word.to_string(),
462 targets: Vec::new(),
463 sub_items: Vec::new(),
464 sub_index: HashMap::new(),
465 category_key: key.map(str::to_string),
466 });
467 self.index.insert(word.to_string(), at);
468 at
469 }
470 };
471 let bucket = &mut self.buckets[at];
472 let targets = if subword.is_empty() {
473 &mut bucket.targets
474 } else {
475 let sub_at = sub_item_slot(bucket, subword, key);
476 &mut bucket.sub_items[sub_at].1
477 };
478 if let Some(link) = link.filter(|link| !link.is_empty()) {
481 targets.push((main.unwrap_or_default().to_string(), link.to_string()));
482 }
483 }
484}
485
486fn sub_item_slot(bucket: &mut Bucket, subword: &str, key: Option<&str>) -> usize {
489 match bucket.sub_index.get(subword) {
490 Some(&at) => at,
491 None => {
492 let at = bucket.sub_items.len();
493 bucket
494 .sub_items
495 .push((subword.to_string(), Vec::new(), key.map(str::to_string)));
496 bucket.sub_index.insert(subword.to_string(), at);
497 at
498 }
499 }
500}
501
502fn add(new: &mut Working, entry: &IndexEntryRecord, uri: Option<&str>) -> Result<(), String> {
504 let main = if entry.main { "main" } else { "" };
505 let key = entry.category_key.as_deref();
506 match entry.entry_type.as_str() {
507 "single" => {
508 let (word, subword) = match split_into(2, "single", &entry.value) {
509 Ok(parts) => (parts[0].clone(), parts[1].clone()),
510 Err(_) => (
511 split_into(1, "single", &entry.value)?.remove(0),
512 String::new(),
513 ),
514 };
515 new.add_entry(&word, &subword, Some(main), uri, key);
516 }
517 "pair" => {
518 let parts = split_into(2, "pair", &entry.value)?;
519 new.add_entry(&parts[0], &parts[1], Some(main), uri, key);
520 new.add_entry(&parts[1], &parts[0], Some(main), uri, key);
521 }
522 "triple" => {
523 let parts = split_into(3, "triple", &entry.value)?;
524 let (first, second, third) = (&parts[0], &parts[1], &parts[2]);
525 new.add_entry(first, &format!("{second} {third}"), Some(main), uri, key);
526 new.add_entry(second, &format!("{third}, {first}"), Some(main), uri, key);
527 new.add_entry(third, &format!("{first} {second}"), Some(main), uri, key);
528 }
529 "see" => {
532 let parts = split_into(2, "see", &entry.value)?;
533 new.add_entry(&parts[0], &format!("see {}", parts[1]), None, None, key);
534 }
535 "seealso" => {
536 let parts = split_into(2, "see", &entry.value)?;
537 new.add_entry(
538 &parts[0],
539 &format!("see also {}", parts[1]),
540 None,
541 None,
542 key,
543 );
544 }
545 other => return Err(format!("unknown index entry type {}", py_repr(Some(other)))),
546 }
547 Ok(())
548}
549
550fn target_sort_key(target: &IndexTarget) -> (bool, String) {
552 (target.0.is_empty(), target.1.clone())
553}
554
555fn entry_sort_key(key: &str, category_key: Option<&str>) -> (u8, String, String) {
558 let folded = match category_key {
559 Some(category) if !category.is_empty() => category,
560 _ => key,
561 };
562 let lc_key = fold(folded);
563 let first = lc_key.chars().next();
564 let group = if !first.is_some_and(py_isalpha) && !lc_key.starts_with('_') {
565 0 } else {
567 1
568 };
569 (group, lc_key, key.to_string())
570}
571
572fn sub_entry_sort_key(name: &str) -> String {
576 let key = fold(name);
577 if key.chars().next().is_some_and(py_isalpha) || key.starts_with('_') {
578 format!("\u{7f}{key}")
579 } else {
580 key
581 }
582}
583
584fn group_of(key: &str, category_key: Option<&str>) -> String {
588 if let Some(category) = category_key {
589 return category.to_string();
590 }
591 let key = key.strip_prefix(RTL_MARK).unwrap_or(key);
592 let Some(first) = key.chars().next() else {
596 return "Symbols".to_string();
597 };
598 let Some(decomposed) = first.nfd().next() else {
599 return "Symbols".to_string();
600 };
601 let letter: String = decomposed.to_uppercase().collect();
602 if (!letter.is_empty() && letter.chars().all(py_isalpha)) || letter == "_" {
603 letter
604 } else {
605 "Symbols".to_string()
606 }
607}
608
609fn fold(key: &str) -> String {
612 let lowered: String = key.to_lowercase().nfd().collect();
613 match lowered.strip_prefix(RTL_MARK) {
614 Some(rest) => rest.to_string(),
615 None => lowered,
616 }
617}
618
619fn py_isalpha(c: char) -> bool {
622 c.is_alphabetic()
623}
624
625fn group_entries(new_list: &mut Vec<Bucket>) {
629 let mut old_key = String::new();
630 let mut old_index: Option<usize> = None;
634 let mut i = 0;
635 while i < new_list.len() {
636 if new_list[i].sub_items.is_empty() {
638 match fixre_match(&new_list[i].key) {
639 Some((prefix, parenthesized)) => {
640 if old_key == prefix {
641 let moved = new_list.remove(i);
642 if let Some(old) = old_index {
643 let bucket = &mut new_list[old];
644 let at = sub_item_slot(
645 bucket,
646 &parenthesized,
647 moved.category_key.as_deref(),
648 );
649 bucket.sub_items[at].1.extend(moved.targets);
650 }
651 continue;
652 }
653 old_key = prefix;
654 }
655 None => old_key = new_list[i].key.clone(),
656 }
657 }
658 old_index = Some(i);
659 i += 1;
660 }
661}
662
663fn fixre_match(key: &str) -> Option<(String, String)> {
666 let limit = key.find('\n').unwrap_or(key.len());
669 let bytes = key.as_bytes();
670 for open in (0..limit.saturating_sub(1)).rev() {
672 if bytes[open] != b' ' || bytes[open + 1] != b'(' {
673 continue;
674 }
675 let rest = &key[open + 2..];
676 let Some(close) = rest.find(['(', ')']) else {
677 continue;
678 };
679 if rest.as_bytes()[close] != b')' {
680 continue;
681 }
682 return Some((
683 key[..open].to_string(),
684 key[open + 1..open + 3 + close].to_string(),
685 ));
686 }
687 None
688}
689
690pub fn snapshot(groups: &[IndexGroup]) -> serde_json::Value {
694 serde_json::to_value(groups).unwrap_or(serde_json::Value::Null)
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::rst::block::index_entry_tuple;
701
702 fn record(
703 entry_type: &str,
704 value: &str,
705 target_id: &str,
706 main: bool,
707 category_key: Option<&str>,
708 ) -> IndexEntryRecord {
709 IndexEntryRecord {
710 entry_type: entry_type.to_string(),
711 value: value.to_string(),
712 target_id: target_id.to_string(),
713 main,
714 category_key: category_key.map(str::to_string),
715 }
716 }
717
718 fn index_of(entries: &[(&str, Vec<IndexEntryRecord>)]) -> (Vec<IndexGroup>, Vec<IndexMessage>) {
719 let mut env = BuildEnvironment::default();
720 for (docname, records) in entries {
721 env.index_entries
722 .insert((*docname).to_string(), records.clone());
723 }
724 let mut messages = Vec::new();
725 let groups = create_index(&env, &|_| Some(String::new()), &mut messages);
726 (groups, messages)
727 }
728
729 #[test]
733 fn every_rendered_tuple_parses_back_to_the_entry_it_came_from() {
734 let cases = [
735 ("single", "Alpha", "index-0", "", None),
736 ("pair", "bread; butter", "index-1", "main", None),
737 ("single", "it's a term", "id", "", Some("K")),
738 ("single", "say \"hi\"", "id", "", None),
739 ("single", "both ' and \"", "id", "", None),
740 ("single", "back\\slash", "id", "", None),
741 ("single", "back\\ slash", "id", "", None),
742 ("single", "tab\there", "id", "", None),
743 ("single", "new\nline", "id", "", None),
744 ("single", "carriage\rreturn", "id", "", None),
745 ("single", "", "id", "", Some("")),
746 ("single", "(None, 'not a tuple')", "id", "", None),
747 ];
748 for (entry_type, value, target_id, main, key) in cases {
749 let rendered = index_entry_tuple(entry_type, value, target_id, main, key);
750 let parsed = parse_index_entries(std::slice::from_ref(&rendered), "a");
751 assert_eq!(
752 parsed,
753 vec![record(entry_type, value, target_id, !main.is_empty(), key)],
754 "round trip failed for {rendered:?}"
755 );
756 }
757 }
758
759 #[test]
768 fn a_stale_string_shaped_entries_attribute_harvests_nothing() {
769 let mut index = Node::elem(INDEX, crate::doctree::Span::ZERO);
770 index.set(
771 "entries",
772 AttrValue::Str("('single',\\ 'Alpha',\\ 'index-0',\\ '',\\ None)".to_string()),
773 );
774 let mut root = Node::elem(crate::doctree::kinds::DOCUMENT, crate::doctree::Span::ZERO);
775 root.children.push(index);
776 let mut doctree = Doctree {
777 root,
778 sources: vec!["<document>".to_string()],
779 };
780
781 let mut env = BuildEnvironment::default();
782 let mut warnings = Vec::new();
783 process_doc(
784 &mut env,
785 "a",
786 &mut doctree,
787 Path::new("a.rst"),
788 &mut warnings,
789 );
790
791 assert_eq!(env.index_entries["a"], Vec::new());
792 assert!(warnings.is_empty(), "{warnings:?}");
793 assert_eq!(doctree.root.children.len(), 1);
795 }
796
797 #[test]
798 fn a_malformed_entries_item_is_dropped_not_panicked_on() {
799 assert!(parse_index_entries(&["('single', 'x'".to_string()], "a").is_empty());
800 assert!(
801 parse_index_entries(&["('a', 'b', 'c', 'd', 'e', 'f')".to_string()], "a").is_empty()
802 );
803 assert!(parse_index_entries(&[String::new()], "a").is_empty());
804 }
805
806 #[test]
809 fn split_index_msg_texts_match_sphinx() {
810 assert_eq!(
811 split_index_msg("pair", "lonely"),
812 Err("invalid pair index entry 'lonely'".to_string())
813 );
814 assert_eq!(
815 split_index_msg("pair", "a; "),
816 Err("invalid pair index entry 'a; '".to_string())
817 );
818 assert_eq!(
819 split_index_msg("triple", "a; b"),
820 Err("invalid triple index entry 'a; b'".to_string())
821 );
822 assert_eq!(
824 split_index_msg("seealso", "lonely"),
825 Err("invalid see index entry 'lonely'".to_string())
826 );
827 assert_eq!(
828 split_index_msg("bogus", "x"),
829 Err("invalid bogus index entry 'x'".to_string())
830 );
831 assert_eq!(
833 split_index_msg("single", "Alpha"),
834 Ok(vec!["Alpha".to_string()])
835 );
836 assert_eq!(
837 split_index_msg("single", "Alpha; Beta"),
838 Ok(vec!["Alpha".to_string(), "Beta".to_string()])
839 );
840 assert_eq!(
842 split_index_msg("pair", "a; b; c"),
843 Ok(vec!["a".to_string(), "b; c".to_string()])
844 );
845 }
846
847 #[test]
851 fn an_unknown_entry_type_warns_with_the_sphinx_text() {
852 let (groups, messages) = index_of(&[("a", vec![record("bogus", "x", "id", false, None)])]);
853 assert!(groups.is_empty());
854 assert_eq!(
855 messages,
856 vec![IndexMessage {
857 docname: "a".to_string(),
858 message: "unknown index entry type 'bogus'".to_string(),
859 }]
860 );
861 }
862
863 #[test]
869 fn an_invalid_value_warns_with_the_value_error_text() {
870 let (_, messages) = index_of(&[("a", vec![record("pair", "lonely", "id", false, None)])]);
871 assert_eq!(
872 messages,
873 vec![IndexMessage {
874 docname: "a".to_string(),
875 message: "invalid pair index entry 'lonely'".to_string(),
876 }]
877 );
878 }
879
880 #[test]
882 fn a_document_with_no_uri_contributes_a_linkless_entry() {
883 let mut env = BuildEnvironment::default();
884 env.index_entries.insert(
885 "a".to_string(),
886 vec![record("single", "Alpha", "id", false, None)],
887 );
888 let groups = create_index(&env, &|_| None, &mut Vec::new());
889 assert_eq!(groups.len(), 1);
890 assert!(groups[0].entries[0].targets.is_empty());
891 }
892
893 #[test]
895 fn main_targets_come_first() {
896 let (groups, _) = index_of(&[(
897 "a",
898 vec![
899 record("single", "Alpha", "z", false, None),
900 record("single", "Alpha", "b", true, None),
901 record("single", "Alpha", "a", false, None),
902 ],
903 )]);
904 assert_eq!(
905 groups[0].entries[0].targets,
906 vec![
907 ("main".to_string(), "#b".to_string()),
908 (String::new(), "#a".to_string()),
909 (String::new(), "#z".to_string()),
910 ]
911 );
912 }
913
914 #[test]
917 fn consecutive_parenthesized_entries_collapse_into_subitems() {
918 let (groups, _) = index_of(&[(
919 "a",
920 vec![
921 record("single", "func() (in module foo)", "f1", false, None),
922 record("single", "func() (in module bar)", "f2", false, None),
923 ],
924 )]);
925 assert_eq!(groups.len(), 1);
926 assert_eq!(groups[0].entries.len(), 1);
927 let entry = &groups[0].entries[0];
928 assert_eq!(entry.name, "func() (in module bar)");
932 assert_eq!(
933 entry
934 .subitems
935 .iter()
936 .map(|item| item.name.as_str())
937 .collect::<Vec<_>>(),
938 vec!["(in module foo)"]
939 );
940 }
941
942 #[test]
945 fn an_entry_with_subitems_is_never_collapsed() {
946 let (groups, _) = index_of(&[(
947 "a",
948 vec![
949 record("pair", "func() (in module foo); detail", "f1", false, None),
950 record("single", "func() (in module goo)", "f2", false, None),
951 ],
952 )]);
953 let names: Vec<&str> = groups
954 .iter()
955 .flat_map(|group| group.entries.iter().map(|entry| entry.name.as_str()))
956 .collect();
957 assert!(
958 names.contains(&"func() (in module foo)") && names.contains(&"func() (in module goo)"),
959 "{names:?}"
960 );
961 }
962
963 #[test]
968 fn sub_entries_that_fold_alike_keep_their_insertion_order() {
969 let (groups, _) = index_of(&[(
970 "a",
971 vec![
972 record("pair", "word; Beta", "b1", false, None),
973 record("pair", "word; beta", "b2", false, None),
974 ],
975 )]);
976 let word = groups
977 .iter()
978 .flat_map(|group| &group.entries)
979 .find(|entry| entry.name == "word")
980 .expect("the `word` entry");
981 assert_eq!(
982 word.subitems
983 .iter()
984 .map(|item| item.name.as_str())
985 .collect::<Vec<_>>(),
986 vec!["Beta", "beta"]
987 );
988 }
989
990 #[test]
991 fn fixre_takes_the_rightmost_parenthesized_group() {
992 assert_eq!(
993 fixre_match("a (b) (c)"),
994 Some(("a (b)".to_string(), "(c)".to_string()))
995 );
996 assert_eq!(
997 fixre_match("a (b) c"),
998 Some(("a".to_string(), "(b)".to_string()))
999 );
1000 assert_eq!(fixre_match("plain"), None);
1001 assert_eq!(
1003 fixre_match("nested (a (b))"),
1004 Some(("nested (a".to_string(), "(b)".to_string()))
1005 );
1006 assert_eq!(
1007 fixre_match("func() (in module foo)"),
1008 Some(("func()".to_string(), "(in module foo)".to_string()))
1009 );
1010 assert_eq!(fixre_match("a\nb (c)"), None);
1012 }
1013
1014 #[test]
1017 fn a_category_key_overrides_the_group_and_the_sort_key() {
1018 let (groups, _) = index_of(&[(
1019 "a",
1020 vec![
1021 record("single", "zebra", "z", false, Some("A")),
1022 record("single", "apple", "a", false, None),
1023 ],
1024 )]);
1025 assert_eq!(
1028 groups
1029 .iter()
1030 .map(|group| group.group.as_str())
1031 .collect::<Vec<_>>(),
1032 vec!["A"]
1033 );
1034 assert_eq!(groups[0].entries[0].name, "zebra");
1036 assert_eq!(groups[0].entries[1].name, "apple");
1037 }
1038
1039 #[test]
1042 fn grouping_follows_the_first_decomposed_character() {
1043 assert_eq!(group_of("42answer", None), "Symbols");
1044 assert_eq!(group_of("_private", None), "_");
1045 assert_eq!(group_of("alpha", None), "A");
1046 assert_eq!(group_of("Ábc", None), "A");
1047 assert_eq!(group_of("--flag", None), "Symbols");
1048 assert_eq!(group_of("x", Some("")), "");
1049 }
1050}