Browse Source

Utilities/Sphinx: Improve word wrap of signatures

Implement logic to support several styles of parsing in the new
signature directive that control where line breaks are allowed in a
signature.

The default is 'smart', which forbids breaks inside of square- or
angle-brackets. The 'verbatim' option forbids all breaks. In all cases,
breaks are always allowed where a newline appears in the source.

This seems to Just Work for most writers, but HTML needs some special
handling that is accomplished by a new CSS rule and assigning the 'nbsp'
class to spaces that are not allowed to break. (ROFF's line wrapping is
rather unfortunate here, as it prefers splitting and hyphenating words
rather than breaking at a space.)
Matthew Woehlke 2 years ago
parent
commit
39ecaa5da1
3 changed files with 93 additions and 12 deletions
  1. 17 0
      Help/dev/documentation.rst
  2. 71 12
      Utilities/Sphinx/cmake.py
  3. 5 0
      Utilities/Sphinx/static/cmake.css

+ 17 - 0
Help/dev/documentation.rst

@@ -301,6 +301,23 @@ The ``signature`` directive generates a hyperlink target for each signature:
   headers, the targets do not work with Sphinx ``:ref:`` syntax, however
   headers, the targets do not work with Sphinx ``:ref:`` syntax, however
   they can be globally referenced using e.g. ``:command:`string(APPEND)```.
   they can be globally referenced using e.g. ``:command:`string(APPEND)```.
 
 
+Although whitespace in the signature is not preserved, by default, line breaks
+are suppressed inside of square- or angle-brackets.  This behavior can be
+controlled using the ``:break:`` option; note, however, that there is no way
+to *force* a line break.  The default value is 'smart'.  Allowable values are:
+
+  ``all``
+    Allow line breaks at any whitespace.
+
+  ``smart`` (default)
+    Allow line breaks at whitespace, except between matched square- or
+    angle-brackets.  For example, if a signature contains the text
+    ``<input>... [OUTPUT_VARIABLE <out-var>]``, a line break would be allowed
+    after ``<input>...`` but not between ``OUTPUT_VARIABLE`` and ``<out-var>``.
+
+  ``verbatim``
+    Allow line breaks only where the source document contains a newline.
+
 The directive treats its content as the documentation of the signature(s).
 The directive treats its content as the documentation of the signature(s).
 Indent the signature documentation accordingly.
 Indent the signature documentation accordingly.
 
 

+ 71 - 12
Utilities/Sphinx/cmake.py

@@ -5,7 +5,7 @@ import os
 import re
 import re
 
 
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Any, cast
+from typing import Any, List, cast
 
 
 # Override much of pygments' CMakeLexer.
 # Override much of pygments' CMakeLexer.
 # We need to parse CMake syntax definitions, not CMake code.
 # We need to parse CMake syntax definitions, not CMake code.
@@ -319,14 +319,69 @@ class CMakeObject(ObjectDescription):
 class CMakeSignatureObject(CMakeObject):
 class CMakeSignatureObject(CMakeObject):
     object_type = 'signature'
     object_type = 'signature'
 
 
+    BREAK_ALL = 'all'
+    BREAK_SMART = 'smart'
+    BREAK_VERBATIM = 'verbatim'
+
+    BREAK_CHOICES = {BREAK_ALL, BREAK_SMART, BREAK_VERBATIM}
+
+    def break_option(argument):
+        return directives.choice(argument, CMakeSignatureObject.BREAK_CHOICES)
+
     option_spec = {
     option_spec = {
         'target': directives.unchanged,
         'target': directives.unchanged,
+        'break': break_option,
     }
     }
 
 
-    def get_signatures(self):
+    def _break_signature_all(sig: str) -> str:
+        return ws_re.sub(' ', sig)
+
+    def _break_signature_verbatim(sig: str) -> str:
+        lines = [ws_re.sub('\xa0', line.strip()) for line in sig.split('\n')]
+        return ' '.join(lines)
+
+    def _break_signature_smart(sig: str) -> str:
+        tokens = []
+        for line in sig.split('\n'):
+            token = ''
+            delim = ''
+
+            for c in line.strip():
+                if len(delim) == 0 and ws_re.match(c):
+                    if len(token):
+                        tokens.append(ws_re.sub('\xa0', token))
+                        token = ''
+                else:
+                    if c == '[':
+                        delim += ']'
+                    elif c == '<':
+                        delim += '>'
+                    elif len(delim) and c == delim[-1]:
+                        delim = delim[:-1]
+                    token += c
+
+            if len(token):
+                tokens.append(ws_re.sub('\xa0', token))
+
+        return ' '.join(tokens)
+
+    def __init__(self, *args, **kwargs):
+        self.targetnames = {}
+        self.break_style = CMakeSignatureObject.BREAK_SMART
+        super().__init__(*args, **kwargs)
+
+    def get_signatures(self) -> List[str]:
         content = nl_escape_re.sub('', self.arguments[0])
         content = nl_escape_re.sub('', self.arguments[0])
         lines = sig_end_re.split(content)
         lines = sig_end_re.split(content)
-        return [ws_re.sub(' ', line.strip()) for line in lines]
+
+        if self.break_style == CMakeSignatureObject.BREAK_VERBATIM:
+            fixup = CMakeSignatureObject._break_signature_verbatim
+        elif self.break_style == CMakeSignatureObject.BREAK_SMART:
+            fixup = CMakeSignatureObject._break_signature_smart
+        else:
+            fixup = CMakeSignatureObject._break_signature_all
+
+        return [fixup(line.strip()) for line in lines]
 
 
     def handle_signature(self, sig, signode):
     def handle_signature(self, sig, signode):
         language = 'cmake'
         language = 'cmake'
@@ -344,7 +399,9 @@ class CMakeSignatureObject(CMakeObject):
                 raise self.warning(error)
                 raise self.warning(error)
 
 
         for classes, value in tokens:
         for classes, value in tokens:
-            if classes:
+            if value == '\xa0':
+                node += nodes.inline(value, value, classes=['nbsp'])
+            elif classes:
                 node += nodes.inline(value, value, classes=classes)
                 node += nodes.inline(value, value, classes=classes)
             else:
             else:
                 node += nodes.Text(value)
                 node += nodes.Text(value)
@@ -354,13 +411,10 @@ class CMakeSignatureObject(CMakeObject):
 
 
         return sig
         return sig
 
 
-    def __init__(self, *args, **kwargs):
-        self.targetnames = {}
-        super().__init__(*args, **kwargs)
-
     def add_target_and_index(self, name, sig, signode):
     def add_target_and_index(self, name, sig, signode):
-        if name in self.targetnames:
-            sigargs = self.targetnames[name]
+        sig = sig.replace('\xa0', ' ')
+        if sig in self.targetnames:
+            sigargs = self.targetnames[sig]
         else:
         else:
             def extract_keywords(params):
             def extract_keywords(params):
                 for p in params:
                 for p in params:
@@ -369,7 +423,7 @@ class CMakeSignatureObject(CMakeObject):
                     else:
                     else:
                         return
                         return
 
 
-            keywords = extract_keywords(name.split('(')[1].split())
+            keywords = extract_keywords(sig.split('(')[1].split())
             sigargs = ' '.join(keywords)
             sigargs = ' '.join(keywords)
         targetname = sigargs.lower()
         targetname = sigargs.lower()
         targetid = nodes.make_id(targetname)
         targetid = nodes.make_id(targetname)
@@ -381,7 +435,7 @@ class CMakeSignatureObject(CMakeObject):
             self.state.document.note_explicit_target(signode)
             self.state.document.note_explicit_target(signode)
 
 
             # Register the signature as a command object.
             # Register the signature as a command object.
-            command = name.split('(')[0].lower()
+            command = sig.split('(')[0].lower()
             refname = f'{command}({sigargs})'
             refname = f'{command}({sigargs})'
             refid = f'command:{command}({targetname})'
             refid = f'command:{command}({targetname})'
 
 
@@ -390,6 +444,8 @@ class CMakeSignatureObject(CMakeObject):
                                node_id=targetid, location=signode)
                                node_id=targetid, location=signode)
 
 
     def run(self):
     def run(self):
+        self.break_style = CMakeSignatureObject.BREAK_ALL
+
         targets = self.options.get('target')
         targets = self.options.get('target')
         if targets is not None:
         if targets is not None:
             signatures = self.get_signatures()
             signatures = self.get_signatures()
@@ -397,6 +453,9 @@ class CMakeSignatureObject(CMakeObject):
             for signature, target in zip(signatures, targets):
             for signature, target in zip(signatures, targets):
                 self.targetnames[signature] = target
                 self.targetnames[signature] = target
 
 
+        self.break_style = (
+            self.options.get('break', CMakeSignatureObject.BREAK_SMART))
+
         return super().run()
         return super().run()
 
 
 class CMakeXRefRole(XRefRole):
 class CMakeXRefRole(XRefRole):

+ 5 - 0
Utilities/Sphinx/static/cmake.css

@@ -40,6 +40,11 @@ div.sphinxsidebarwrapper {
   font-weight: normal;
   font-weight: normal;
 }
 }
 
 
+/* Implement non-breaking spaces in signatures. */
+.nbsp {
+  white-space: nowrap;
+}
+
 /* Remove unwanted margin in case list item contains a div-wrapping
 /* Remove unwanted margin in case list item contains a div-wrapping
    directive like `.. versionadded` or `.. deprecated`. */
    directive like `.. versionadded` or `.. deprecated`. */
 dd > :first-child > p {
 dd > :first-child > p {