"""Index entries adapters for sphinx.environment."""
from __future__ import annotations
import re
import unicodedata
from itertools import groupby
from typing import TYPE_CHECKING
from sphinx.errors import NoUri
from sphinx.locale import _, __
from sphinx.util import logging
from sphinx.util.index_entries import _split_into
if TYPE_CHECKING:
from typing import Literal
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
type _IndexEntryTarget = tuple[str | None, str | Literal[False]]
type _IndexEntryTargets = list[_IndexEntryTarget]
type _IndexEntryCategoryKey = str | None
type _IndexEntrySubItems = dict[
str,
tuple[_IndexEntryTargets, _IndexEntryCategoryKey],
]
type _IndexEntry = tuple[
_IndexEntryTargets,
_IndexEntrySubItems,
_IndexEntryCategoryKey,
]
type _IndexEntryMap = dict[str, _IndexEntry]
# Used by ``create_index()`` for 'the real index'
type _RealIndexEntry = tuple[
str,
tuple[
_IndexEntryTargets,
list[tuple[str, _IndexEntryTargets]],
_IndexEntryCategoryKey,
],
]
type _RealIndexEntries = list[_RealIndexEntry]
type _Index = list[tuple[str, _RealIndexEntries]]
logger = logging.getLogger(__name__)
class IndexEntries:
def __init__(self, env: BuildEnvironment) -> None:
self.env = env
def create_index(
self,
builder: Builder,
group_entries: bool = True,
_fixre: re.Pattern[str] = re.compile(r'(.*) ([(][^()]*[)])'),
) -> _Index:
"""Create the real index from the collected index entries."""
new: _IndexEntryMap = {}
rel_uri: str | Literal[False]
index_domain = self.env.domains.index_domain
for docname, entries in index_domain.entries.items():
try:
rel_uri = builder.get_relative_uri('genindex', docname)
except NoUri:
rel_uri = False
# new entry types must be listed in directives/other.py!
for entry_type, value, target_id, main, category_key in entries:
uri = rel_uri is not False and f'{rel_uri}#{target_id}'
try:
if entry_type == 'single':
try:
entry, sub_entry = _split_into(2, 'single', value)
except ValueError:
(entry,) = _split_into(1, 'single', value)
sub_entry = ''
_add_entry(
entry, sub_entry, main, dic=new, link=uri, key=category_key
)
elif entry_type == 'pair':
first, second = _split_into(2, 'pair', value)
_add_entry(
first, second, main, dic=new, link=uri, key=category_key
)
_add_entry(
second, first, main, dic=new, link=uri, key=category_key
)
elif entry_type == 'triple':
first, second, third = _split_into(3, 'triple', value)
_add_entry(
first,
second + ' ' + third,
main,
dic=new,
link=uri,
key=category_key,
)
_add_entry(
second,
third + ', ' + first,
main,
dic=new,
link=uri,
key=category_key,
)
_add_entry(
third,
first + ' ' + second,
main,
dic=new,
link=uri,
key=category_key,
)
elif entry_type == 'see':
first, second = _split_into(2, 'see', value)
_add_entry(
first,
_('see %s') % second,
None,
dic=new,
link=False,
key=category_key,
)
elif entry_type == 'seealso':
first, second = _split_into(2, 'see', value)
_add_entry(
first,
_('see also %s') % second,
None,
dic=new,
link=False,
key=category_key,
)
else:
logger.warning(
__('unknown index entry type %r'),
entry_type,
location=docname,
type='index',
)
except ValueError as err:
logger.warning(str(err), location=docname, type='index')
for targets, sub_items, _category_key in new.values():
targets.sort(key=_key_func_0)
for sub_targets, _sub_category_key in sub_items.values():
sub_targets.sort(key=_key_func_0)
new_list: list[tuple[str, _IndexEntry]] = sorted(new.items(), key=_key_func_1)
if group_entries:
# fixup entries: transform
# func() (in module foo)
# func() (in module bar)
# into
# func()
# (in module foo)
# (in module bar)
old_key = ''
old_sub_items: _IndexEntrySubItems = {}
i = 0
while i < len(new_list):
key, (targets, sub_items, category_key) = new_list[i]
# cannot move if it has sub_items; structure gets too complex
if not sub_items:
m = _fixre.match(key)
if m:
if old_key == m.group(1):
# prefixes match: add entry as subitem of the
# previous entry
prev = old_sub_items.setdefault(m[2], ([], category_key))
prev[0].extend(targets)
del new_list[i]
continue
old_key = m.group(1)
else:
old_key = key
old_sub_items = sub_items
i += 1
grouped = []
for group_key, group in groupby(new_list, _group_by_func):
group_list = []
for group_entry in group:
entry_key, (targets, sub_items, category_key) = group_entry
pairs = [
(sub_key, sub_targets)
for (sub_key, (sub_targets, _sub_category_key)) in sub_items.items()
]
pairs.sort(key=_key_func_2)
group_list.append((entry_key, (targets, pairs, category_key)))
grouped.append((group_key, group_list))
return grouped
def _add_entry(
word: str,
subword: str,
main: str | None,
*,
dic: _IndexEntryMap,
link: str | Literal[False],
key: _IndexEntryCategoryKey,
) -> None:
entry = dic.setdefault(word, ([], {}, key))
if subword:
targets = entry[1].setdefault(subword, ([], key))[0]
else:
targets = entry[0]
if link:
targets.append((main, link))
def _key_func_0(entry: _IndexEntryTarget) -> tuple[bool, str | Literal[False]]:
"""Sort the index entries for same keyword."""
main, uri = entry
return not main, uri # show main entries at first
def _key_func_1(entry: tuple[str, _IndexEntry]) -> tuple[tuple[int, str], str]:
"""Sort the index entries"""
key, (_targets, _sub_items, category_key) = entry
if category_key:
# using the specified category key to sort
key = category_key
lc_key = unicodedata.normalize('NFD', key.lower())
lc_key = lc_key.removeprefix('\N{RIGHT-TO-LEFT MARK}')
if not lc_key[0:1].isalpha() and not lc_key.startswith('_'):
# put symbols at the front of the index (0)
group = 0
else:
# put non-symbol characters at the following group (1)
group = 1
# ensure a deterministic order *within* letters by also sorting on
# the entry itself
return (group, lc_key), entry[0]
def _key_func_2(entry: tuple[str, _IndexEntryTargets]) -> str:
"""Sort the sub-index entries"""
key = unicodedata.normalize('NFD', entry[0].lower())
key = key.removeprefix('\N{RIGHT-TO-LEFT MARK}')
if key[0:1].isalpha() or key.startswith('_'):
key = chr(127) + key
return key
def _group_by_func(entry: tuple[str, _IndexEntry]) -> str:
"""Group the entries by letter or category key."""
key, (_targets, _sub_items, category_key) = entry
if category_key is not None:
return category_key
# now calculate the key
key = key.removeprefix('\N{RIGHT-TO-LEFT MARK}')
letter = unicodedata.normalize('NFD', key[0])[0].upper()
if letter.isalpha() or letter == '_':
return letter
# get all other symbols under one heading
return _('Symbols')