Repository: sphinx-doc/sphinx

Issue:
latex writer incorrectly handles indexing a sole semi-colon character
Consider the following source:

```
.. index::
   single: clause
   single: suite
   single: ; (semicolon)
```

which can be seen in [Python documentation sources](https://github.com/python/cpython/blob/3.7/Doc/reference/compound_stmts.rst)

When processed by LaTeX writer the semi-colon is mistakendly interpreted as separator and as a result an invalid empty index entry arises.

When the PDF is built with `latex_use_xindy = True` this causes failed build with this error:

```
ERROR: CHAR: index 0 should be less than the length of the string
```

This arose in discussion of #5561 issue.

### Environment info
- OS: Mac
- Python version: 3.6.4
- Sphinx version: 1.8.2+



Discussion:

Patch:
diff --git a/sphinx/texinputs/python.ist b/sphinx/texinputs/python.ist
index 7a1c06fbd..70536a668 100644
--- a/sphinx/texinputs/python.ist
+++ b/sphinx/texinputs/python.ist
@@ -4,6 +4,9 @@ heading_prefix "  \\bigletter "
 
 preamble "\\begin{sphinxtheindex}
 \\let\\bigletter\\sphinxstyleindexlettergroup
+\\let\\spxpagem \\sphinxstyleindexpagemain
+\\let\\spxentry \\sphinxstyleindexentry
+\\let\\spxextra \\sphinxstyleindexextra
 
 "
 
diff --git a/sphinx/texinputs/sphinx.sty b/sphinx/texinputs/sphinx.sty
index 62d119e02..f16035973 100644
--- a/sphinx/texinputs/sphinx.sty
+++ b/sphinx/texinputs/sphinx.sty
@@ -1623,8 +1623,11 @@
 
 % additional customizable styling
 \def\sphinxstyleindexentry   #1{\texttt{#1}}
-\def\sphinxstyleindexextra   #1{ \emph{(#1)}}
+\def\sphinxstyleindexextra   #1{ (\emph{#1})}
 \def\sphinxstyleindexpageref #1{, \pageref{#1}}
+\def\sphinxstyleindexpagemain#1{\textbf{#1}}
+\protected\def\spxentry#1{#1}% will get \let to \sphinxstyleindexentry in index
+\protected\def\spxextra#1{#1}% will get \let to \sphinxstyleindexextra in index
 \def\sphinxstyleindexlettergroup #1%
     {{\Large\sffamily#1}\nopagebreak\vspace{1mm}}
 \def\sphinxstyleindexlettergroupDefault #1%
diff --git a/sphinx/texinputs/sphinx.xdy b/sphinx/texinputs/sphinx.xdy
index 17b065600..0d02ef337 100644
--- a/sphinx/texinputs/sphinx.xdy
+++ b/sphinx/texinputs/sphinx.xdy
@@ -3,11 +3,12 @@
 ;; Unfortunately xindy is out-of-the-box hyperref-incompatible.  This
 ;; configuration is a workaround, which requires to pass option
 ;; hyperindex=false to hyperref.
-;; textit and emph not currently used by Sphinx LaTeX writer.
-(define-attributes (("textbf" "textit" "emph" "default")))
+;; textit and emph not currently used, spxpagem replaces former textbf
+(define-attributes (("textbf" "textit" "emph" "spxpagem" "default")))
 (markup-locref :open "\textbf{\hyperpage{" :close "}}" :attr "textbf")
 (markup-locref :open "\textit{\hyperpage{" :close "}}" :attr "textit")
 (markup-locref :open "\emph{\hyperpage{" :close "}}" :attr "emph")
+(markup-locref :open "\spxpagem{\hyperpage{" :close "}}" :attr "spxpagem")
 (markup-locref :open "\hyperpage{" :close "}" :attr "default")
 
 (require "numeric-sort.xdy")
@@ -193,6 +194,9 @@
 (markup-index :open  "\begin{sphinxtheindex}
 \let\lettergroup\sphinxstyleindexlettergroup
 \let\lettergroupDefault\sphinxstyleindexlettergroupDefault
+\let\spxpagem\sphinxstyleindexpagemain
+\let\spxentry\sphinxstyleindexentry
+\let\spxextra\sphinxstyleindexextra
 
 "
 	      :close "
diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py
index 7febdef7d..e9d931f9e 100644
--- a/sphinx/writers/latex.py
+++ b/sphinx/writers/latex.py
@@ -169,6 +169,8 @@
     },
 }  # type: Dict[unicode, Dict[unicode, unicode]]
 
+EXTRA_RE = re.compile(r'^(.*\S)\s+\(([^()]*)\)\s*$')
+
 
 class collected_footnote(nodes.footnote):
     """Footnotes that are collected are assigned this class."""
@@ -1884,8 +1886,8 @@ def depart_attribution(self, node):
         # type: (nodes.Node) -> None
         self.body.append('\n\\end{flushright}\n')
 
-    def visit_index(self, node, scre=re.compile(r';\s*')):
-        # type: (nodes.Node, Pattern) -> None
+    def visit_index(self, node, scre = None):
+        # type: (nodes.Node, None) -> None
         def escape(value):
             value = self.encode(value)
             value = value.replace(r'\{', r'\sphinxleftcurlybrace{}')
@@ -1895,33 +1897,57 @@ def escape(value):
             value = value.replace('!', '"!')
             return value
 
+        def style(string):
+            match = EXTRA_RE.match(string)
+            if match:
+                return match.expand(r'\\spxentry{\1}\\spxextra{\2}')
+            else:
+                return '\\spxentry{%s}' % string
+
+        if scre:
+            warnings.warn(('LaTeXTranslator.visit_index() optional argument '
+                           '"scre" is deprecated. It is ignored.'),
+                          RemovedInSphinx30Warning, stacklevel=2)
         if not node.get('inline', True):
             self.body.append('\n')
         entries = node['entries']
         for type, string, tid, ismain, key_ in entries:
             m = ''
             if ismain:
-                m = '|textbf'
+                m = '|spxpagem'
             try:
                 if type == 'single':
-                    p = scre.sub('!', escape(string))
-                    self.body.append(r'\index{%s%s}' % (p, m))
+                    try:
+                        p1, p2 = [escape(x) for x in split_into(2, 'single', string)]
+                        P1, P2 = style(p1), style(p2)
+                        self.body.append(r'\index{%s@%s!%s@%s%s}' % (p1, P1, p2, P2, m))
+                    except ValueError:
+                        p = escape(split_into(1, 'single', string)[0])
+                        P = style(p)
+                        self.body.append(r'\index{%s@%s%s}' % (p, P, m))
                 elif type == 'pair':
                     p1, p2 = [escape(x) for x in split_into(2, 'pair', string)]
-                    self.body.append(r'\index{%s!%s%s}\index{%s!%s%s}' %
-                                     (p1, p2, m, p2, p1, m))
+                    P1, P2 = style(p1), style(p2)
+                    self.body.append(r'\index{%s@%s!%s@%s%s}\index{%s@%s!%s@%s%s}' %
+                                     (p1, P1, p2, P2, m, p2, P2, p1, P1, m))
                 elif type == 'triple':
                     p1, p2, p3 = [escape(x) for x in split_into(3, 'triple', string)]
+                    P1, P2, P3 = style(p1), style(p2), style(p3)
                     self.body.append(
-                        r'\index{%s!%s %s%s}\index{%s!%s, %s%s}'
-                        r'\index{%s!%s %s%s}' %
-                        (p1, p2, p3, m, p2, p3, p1, m, p3, p1, p2, m))
+                        r'\index{%s@%s!%s %s@%s %s%s}'
+                        r'\index{%s@%s!%s, %s@%s, %s%s}'
+                        r'\index{%s@%s!%s %s@%s %s%s}' %
+                        (p1, P1, p2, p3, P2, P3, m,
+                         p2, P2, p3, p1, P3, P1, m,
+                         p3, P3, p1, p2, P1, P2, m))
                 elif type == 'see':
                     p1, p2 = [escape(x) for x in split_into(2, 'see', string)]
-                    self.body.append(r'\index{%s|see{%s}}' % (p1, p2))
+                    P1 = style(p1)
+                    self.body.append(r'\index{%s@%s|see{%s}}' % (p1, P1, p2))
                 elif type == 'seealso':
                     p1, p2 = [escape(x) for x in split_into(2, 'seealso', string)]
-                    self.body.append(r'\index{%s|see{%s}}' % (p1, p2))
+                    P1 = style(p1)
+                    self.body.append(r'\index{%s@%s|see{%s}}' % (p1, P1, p2))
                 else:
                     logger.warning(__('unknown index entry type %s found'), type)
             except ValueError as err:


Test patch:
diff --git a/tests/test_build_latex.py b/tests/test_build_latex.py
index b5eabc4ad..580b8a93e 100644
--- a/tests/test_build_latex.py
+++ b/tests/test_build_latex.py
@@ -1199,9 +1199,13 @@ def test_latex_index(app, status, warning):
     app.builder.build_all()
 
     result = (app.outdir / 'Python.tex').text(encoding='utf8')
-    assert 'A \\index{famous}famous \\index{equation}equation:\n' in result
-    assert '\n\\index{Einstein}\\index{relativity}\\ignorespaces \nand' in result
-    assert '\n\\index{main \\sphinxleftcurlybrace{}}\\ignorespaces ' in result
+    assert ('A \\index{famous@\\spxentry{famous}}famous '
+            '\\index{equation@\\spxentry{equation}}equation:\n' in result)
+    assert ('\n\\index{Einstein@\\spxentry{Einstein}}'
+            '\\index{relativity@\\spxentry{relativity}}'
+            '\\ignorespaces \nand') in result
+    assert ('\n\\index{main \\sphinxleftcurlybrace{}@\\spxentry{'
+            'main \\sphinxleftcurlybrace{}}}\\ignorespaces ' in result)
 
 
 @pytest.mark.sphinx('latex', testroot='latex-equations')
@@ -1269,20 +1273,22 @@ def test_latex_glossary(app, status, warning):
     app.builder.build_all()
 
     result = (app.outdir / 'test.tex').text(encoding='utf8')
-    assert (u'\\item[{änhlich\\index{änhlich|textbf}\\phantomsection'
+    assert (u'\\item[{änhlich\\index{änhlich@\\spxentry{änhlich}|spxpagem}'
+            r'\phantomsection'
             r'\label{\detokenize{index:term-anhlich}}}] \leavevmode' in result)
-    assert (r'\item[{boson\index{boson|textbf}\phantomsection'
+    assert (r'\item[{boson\index{boson@\spxentry{boson}|spxpagem}\phantomsection'
             r'\label{\detokenize{index:term-boson}}}] \leavevmode' in result)
-    assert (r'\item[{\sphinxstyleemphasis{fermion}\index{fermion|textbf}'
+    assert (r'\item[{\sphinxstyleemphasis{fermion}'
+            r'\index{fermion@\spxentry{fermion}|spxpagem}'
             r'\phantomsection'
             r'\label{\detokenize{index:term-fermion}}}] \leavevmode' in result)
-    assert (r'\item[{tauon\index{tauon|textbf}\phantomsection'
+    assert (r'\item[{tauon\index{tauon@\spxentry{tauon}|spxpagem}\phantomsection'
             r'\label{\detokenize{index:term-tauon}}}] \leavevmode'
-            r'\item[{myon\index{myon|textbf}\phantomsection'
+            r'\item[{myon\index{myon@\spxentry{myon}|spxpagem}\phantomsection'
             r'\label{\detokenize{index:term-myon}}}] \leavevmode'
-            r'\item[{electron\index{electron|textbf}\phantomsection'
+            r'\item[{electron\index{electron@\spxentry{electron}|spxpagem}\phantomsection'
             r'\label{\detokenize{index:term-electron}}}] \leavevmode' in result)
-    assert (u'\\item[{über\\index{über|textbf}\\phantomsection'
+    assert (u'\\item[{über\\index{über@\\spxentry{über}|spxpagem}\\phantomsection'
             r'\label{\detokenize{index:term-uber}}}] \leavevmode' in result)