chiark / gitweb /
documentation/python: hook up the search.
authorVladimír Vondruš <mosra@centrum.cz>
Thu, 18 Jul 2019 08:45:48 +0000 (10:45 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Thu, 18 Jul 2019 15:48:56 +0000 (17:48 +0200)
12 files changed:
documentation/python.py
documentation/templates/python/opensearch.xml [new file with mode: 0644]
documentation/test_python/CMakeLists.txt
documentation/test_python/layout_search_binary/index.html [new file with mode: 0644]
documentation/test_python/layout_search_open_search/index.html [new file with mode: 0644]
documentation/test_python/layout_search_open_search/opensearch.xml [new file with mode: 0644]
documentation/test_python/search/search/__init__.py [new file with mode: 0644]
documentation/test_python/search/search/pybind.cpp [new file with mode: 0644]
documentation/test_python/search/search/sub.py [new file with mode: 0644]
documentation/test_python/search_long_suffix_length/search_long_suffix_length.cpp [new file with mode: 0644]
documentation/test_python/test_layout.py
documentation/test_python/test_search.py [new file with mode: 0644]

index 2a6124e23ca01aae06f22904d73137eb0126d8a2..e9e4a78aeadc6f42f43f4826e3b733a1336a8843 100755 (executable)
@@ -51,7 +51,7 @@ from docutils.transforms import Transform
 
 import jinja2
 
-from _search import searchdata_format_version
+from _search import CssClass, ResultFlag, ResultMap, Trie, serialize_search_data, base85encode_search_data, searchdata_format_version, searchdata_filename, searchdata_filename_b85
 
 sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), '../plugins'))
 import m.htmlsanity
@@ -61,19 +61,38 @@ default_templates = os.path.join(os.path.dirname(os.path.realpath(__file__)), 't
 special_pages = ['index', 'modules', 'classes', 'pages']
 
 class EntryType(Enum):
-    SPECIAL = 0 # one of files from special_pages
+    # Order must match the search_type_map below; first value is reserved for
+    # ResultFlag.ALIAS
     PAGE = 1
     MODULE = 2
     CLASS = 3
-    ENUM = 4
-    ENUM_VALUE = 5
-    FUNCTION = 6
+    FUNCTION = 4
+    PROPERTY = 5
+    ENUM = 6
+    ENUM_VALUE = 7
+    DATA = 8
+
+    # Types not exposed to search are below
+
+    # One of files from special_pages. Doesn't make sense to include in the
+    # search.
+    SPECIAL = 9
     # Denotes a potentially overloaded pybind11 function. Has to be here to
     # be able to distinguish between zero-argument normal and pybind11
-    # functions.
-    OVERLOADED_FUNCTION = 7
-    PROPERTY = 8
-    DATA = 9
+    # functions. To search it's exposed as FUNCTION.
+    OVERLOADED_FUNCTION = 10
+
+# Order must match the EntryType above
+search_type_map = [
+    (CssClass.SUCCESS, "page"),
+    (CssClass.PRIMARY, "module"),
+    (CssClass.PRIMARY, "class"),
+    (CssClass.INFO, "func"),
+    (CssClass.WARNING, "property"),
+    (CssClass.PRIMARY, "enum"),
+    (CssClass.DEFAULT, "enum val"),
+    (CssClass.DEFAULT, "data")
+]
 
 def default_url_formatter(type: EntryType, path: List[str]) -> Tuple[str, str]:
     # TODO: what about nested pages, how to format?
@@ -163,6 +182,7 @@ class State:
         self.hooks_post_run: List = []
 
         self.name_map: Dict[str, Empty] = {}
+        self.search: List[Any] = []
 
         self.crawled: Set[object] = set()
 
@@ -925,11 +945,33 @@ def extract_enum_doc(state: State, entry: Empty):
             value.summary = ''
             out.values += [value]
 
+    if not state.config['SEARCH_DISABLED']:
+        page_url = state.name_map['.'.join(entry.path[:-1])].url
+
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.ENUM)
+        result.url = '{}#{}'.format(page_url, out.id)
+        result.prefix = entry.path[:-1]
+        result.name = entry.path[-1]
+        state.search += [result]
+
+        for value in out.values:
+            result = Empty()
+            result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.ENUM_VALUE)
+            result.url = '{}#{}'.format(page_url, value.id)
+            result.prefix = entry.path
+            result.name = value.name
+            state.search += [result]
+
     return out
 
 def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
     assert inspect.isfunction(entry.object) or inspect.ismethod(entry.object) or inspect.isroutine(entry.object)
 
+    # Enclosing page URL for search
+    if not state.config['SEARCH_DISABLED']:
+        page_url = state.name_map['.'.join(entry.path[:-1])].url
+
     # Extract the signature from the docstring for pybind11, since it can't
     # expose it to the metadata: https://github.com/pybind/pybind11/issues/990
     # What's not solvable with metadata, however, are function overloads ---
@@ -1034,6 +1076,18 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
             # thus name alone is not enough.
             out.id = state.config['ID_FORMATTER'](EntryType.OVERLOADED_FUNCTION, entry.path[-1:] + arg_types)
 
+            if not state.config['SEARCH_DISABLED']:
+                result = Empty()
+                result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.FUNCTION)
+                result.url = '{}#{}'.format(page_url, out.id)
+                result.prefix = entry.path[:-1]
+                result.name = entry.path[-1]
+                result.params = []
+                for i in range(len(out.params)):
+                    param = out.params[i]
+                    result.params += ['{}: {}'.format(param.name, make_relative_name(state, entry.path, arg_types[i])) if arg_types[i] else param.name]
+                state.search += [result]
+
             overloads += [out]
 
         return overloads
@@ -1094,6 +1148,15 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
             out.params = [param]
             out.type = None
 
+        if not state.config['SEARCH_DISABLED']:
+            result = Empty()
+            result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.FUNCTION)
+            result.url = '{}#{}'.format(page_url, out.id)
+            result.prefix = entry.path[:-1]
+            result.name = entry.path[-1]
+            result.params = []
+            state.search += [result]
+
         return [out]
 
 def extract_property_doc(state: State, parent, entry: Empty):
@@ -1210,6 +1273,14 @@ def extract_property_doc(state: State, parent, entry: Empty):
         else:
             out.type = None
 
+    if not state.config['SEARCH_DISABLED']:
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.PROPERTY)
+        result.url = '{}#{}'.format(state.name_map['.'.join(entry.path[:-1])].url, out.id)
+        result.prefix = entry.path[:-1]
+        result.name = entry.path[-1]
+        state.search += [result]
+
     return out
 
 def extract_data_doc(state: State, parent, entry: Empty):
@@ -1243,6 +1314,14 @@ def extract_data_doc(state: State, parent, entry: Empty):
         out.summary = render_inline_rst(state, state.data_docs[path_str]['summary'])
         del state.data_docs[path_str]
 
+    if not state.config['SEARCH_DISABLED']:
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.DATA)
+        result.url = '{}#{}'.format(state.name_map['.'.join(entry.path[:-1])].url, out.id)
+        result.prefix = entry.path[:-1]
+        result.name = entry.path[-1]
+        state.search += [result]
+
     return out
 
 def render(config, template: str, page, env: jinja2.Environment):
@@ -1320,6 +1399,14 @@ def render_module(state: State, path, module, env):
         else: # pragma: no cover
             assert False
 
+    if not state.config['SEARCH_DISABLED']:
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.MODULE)
+        result.url = page.url
+        result.prefix = path[:-1]
+        result.name = path[-1]
+        state.search += [result]
+
     render(state.config, 'module.html', page, env)
 
 def render_class(state: State, path, class_, env):
@@ -1398,6 +1485,14 @@ def render_class(state: State, path, class_, env):
         else: # pragma: no cover
             assert False
 
+    if not state.config['SEARCH_DISABLED']:
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.CLASS)
+        result.url = page.url
+        result.prefix = path[:-1]
+        result.name = path[-1]
+        state.search += [result]
+
     render(state.config, 'class.html', page, env)
 
 # Extracts image paths and transforms them to just the filenames
@@ -1527,10 +1622,93 @@ def render_page(state: State, path, input_filename, env):
     module_entry.summary = page.summary
     module_entry.name = breadcrumb[-1][0]
 
-    # Render the output file
+    if not state.config['SEARCH_DISABLED']:
+        result = Empty()
+        result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.PAGE)
+        result.url = page.url
+        result.prefix = path[:-1]
+        result.name = path[-1]
+        state.search += [result]
+
     render(state.config, 'page.html', page, env)
 
-def run(basedir, config, templates):
+def is_html_safe(string):
+    return '<' not in string and '>' not in string and '&' not in string and '"' not in string and '\'' not in string
+
+def build_search_data(state: State, merge_subtrees=True, add_lookahead_barriers=True, merge_prefixes=True) -> bytearray:
+    trie = Trie()
+    map = ResultMap()
+
+    symbol_count = 0
+    for result in state.search:
+        # Decide on prefix joiner
+        if EntryType(result.flags.type) in [EntryType.MODULE, EntryType.CLASS, EntryType.FUNCTION, EntryType.PROPERTY, EntryType.ENUM, EntryType.ENUM_VALUE, EntryType.DATA]:
+            joiner = '.'
+        elif EntryType(result.flags.type) == EntryType.PAGE:
+            joiner = ' » '
+        else:
+            assert False # pragma: no cover
+
+        # Handle function arguments
+        name_with_args = result.name
+        name = result.name
+        suffix_length = 0
+        if hasattr(result, 'params') and result.params is not None:
+            # Some very heavily annotated function parameters might cause the
+            # suffix_length to exceed 256, which won't fit into the serialized
+            # search data. However that *also* won't fit in the search result
+            # list so there's no point in storing so much. Truncate it to 48
+            # chars which should fit the full function name in the list in most
+            # cases, yet be still long enough to be able to distinguish
+            # particular overloads.
+            # TODO: the suffix_length has to be calculated on UTF-8 and I
+            # am (un)escaping a lot back and forth here -- needs to be
+            # cleaned up
+            params = ', '.join(result.params)
+            if len(params) > 49:
+                params = params[:48] + '…'
+            name_with_args += '(' + params + ')'
+            suffix_length += len(params.encode('utf-8')) + 2
+
+        complete_name = joiner.join(result.prefix + [name_with_args])
+        assert is_html_safe(complete_name) # this is not C++, so no <>&
+        index = map.add(complete_name, result.url, suffix_length=suffix_length, flags=result.flags)
+
+        # Add functions the second time with () appended, everything is the
+        # same except for suffix length which is 2 chars shorter
+        if hasattr(result, 'params') and result.params is not None:
+            index_args = map.add(complete_name, result.url,
+                suffix_length=suffix_length - 2, flags=result.flags)
+
+        # Add the result multiple times with all possible prefixes
+        prefixed_name = result.prefix + [name]
+        for i in range(len(prefixed_name)):
+            lookahead_barriers = []
+            name = ''
+            for j in prefixed_name[i:]:
+                if name:
+                    lookahead_barriers += [len(name)]
+                    name += joiner
+                name += html.unescape(j)
+            trie.insert(name.lower(), index, lookahead_barriers=lookahead_barriers if add_lookahead_barriers else [])
+
+            # Add functions the second time with () appended, referencing
+            # the other result that expects () appended. The lookahead
+            # barrier is at the ( character to avoid the result being shown
+            # twice.
+            if hasattr(result, 'params') and result.params is not None:
+                trie.insert(name.lower() + '()', index_args, lookahead_barriers=lookahead_barriers + [len(name)] if add_lookahead_barriers else [])
+
+        # Add this symbol to total symbol count
+        symbol_count += 1
+
+    # For each node in the trie sort the results so the found items have sane
+    # order by default
+    trie.sort(map)
+
+    return serialize_search_data(trie, map, search_type_map, symbol_count, merge_subtrees=merge_subtrees, merge_prefixes=merge_prefixes)
+
+def run(basedir, config, *, templates=default_templates, search_add_lookahead_barriers=True, search_merge_subtrees=True, search_merge_prefixes=True):
     # Populate the INPUT, if not specified, make it absolute
     if config['INPUT'] is None: config['INPUT'] = basedir
     else: config['INPUT'] = os.path.join(basedir, config['INPUT'])
@@ -1731,6 +1909,33 @@ def run(basedir, config, templates):
         page.breadcrumb = [(config['PROJECT_TITLE'], url)]
         render(config, 'page.html', page, env)
 
+    if not state.config['SEARCH_DISABLED']:
+        logging.debug("building search data for {} symbols".format(len(state.search)))
+
+        data = build_search_data(state, add_lookahead_barriers=search_add_lookahead_barriers, merge_subtrees=search_merge_subtrees, merge_prefixes=search_merge_prefixes)
+
+        if state.config['SEARCH_DOWNLOAD_BINARY']:
+            with open(os.path.join(config['OUTPUT'], searchdata_filename), 'wb') as f:
+                f.write(data)
+        else:
+            with open(os.path.join(config['OUTPUT'], searchdata_filename_b85), 'wb') as f:
+                f.write(base85encode_search_data(data))
+
+        # OpenSearch metadata, in case we have the base URL
+        if state.config['SEARCH_BASE_URL']:
+            logging.debug("writing OpenSearch metadata file")
+
+            template = env.get_template('opensearch.xml')
+            rendered = template.render(**state.config)
+            output = os.path.join(config['OUTPUT'], 'opensearch.xml')
+            with open(output, 'wb') as f:
+                f.write(rendered.encode('utf-8'))
+                # Add back a trailing newline so we don't need to bother with
+                # patching test files to include a trailing newline to make Git
+                # happy. Can't use keep_trailing_newline because that'd add it
+                # also for nested templates :(
+                f.write(b'\n')
+
     # Copy referenced files
     for i in config['STYLESHEETS'] + config['EXTRA_FILES'] + ([config['FAVICON'][0]] if config['FAVICON'] else []) + list(state.external_data) + ([] if config['SEARCH_DISABLED'] else ['search.js']):
         # Skip absolute URLs
@@ -1769,4 +1974,4 @@ if __name__ == '__main__': # pragma: no cover
     else:
         logging.basicConfig(level=logging.INFO)
 
-    run(os.path.dirname(os.path.abspath(args.conf)), config, os.path.abspath(args.templates))
+    run(os.path.dirname(os.path.abspath(args.conf)), config, templates=os.path.abspath(args.templates))
diff --git a/documentation/templates/python/opensearch.xml b/documentation/templates/python/opensearch.xml
new file mode 100644 (file)
index 0000000..6d01918
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
+  <ShortName>{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} {{ PROJECT_SUBTITLE }}{% endif %}</ShortName>
+  <Description>Search {{ PROJECT_TITLE }} documentation</Description>
+  {% if FAVICON %}
+  <Image type="{{ FAVICON[1] }}">{{ SEARCH_BASE_URL|urljoin(FAVICON[0])|e }}</Image>
+  {% endif %}
+  <Url type="text/html" template="{{ SEARCH_BASE_URL }}?q={searchTerms}#search"/>
+</OpenSearchDescription>
index e3966fdbc318f759ec7614510966695e8c8bc166..5dc0a69000b7bf2fa806801f401c222d1b15b4ad 100644 (file)
@@ -27,9 +27,9 @@ project(McssDocumentationPybindTests)
 
 find_package(pybind11 CONFIG REQUIRED)
 
-foreach(target signatures enums submodules type_links)
-    pybind11_add_module(pybind_${target} pybind_${target}/pybind_${target}.cpp)
-    set_target_properties(pybind_${target} PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/pybind_${target})
+foreach(target pybind_signatures pybind_enums pybind_submodules pybind_type_links search_long_suffix_length)
+    pybind11_add_module(${target} ${target}/${target}.cpp)
+    set_target_properties(${target} PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${target})
 endforeach()
 
 # Need a special location for this one
@@ -38,6 +38,12 @@ set_target_properties(pybind_link_formatting PROPERTIES
     OUTPUT_NAME pybind
     LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/link_formatting/link_formatting)
 
+# Need a special location for this one
+pybind11_add_module(pybind_search search/search/pybind.cpp)
+set_target_properties(pybind_search PROPERTIES
+    OUTPUT_NAME pybind
+    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/search/search)
+
 # Need a special name for this one
 pybind11_add_module(pybind_name_mapping pybind_name_mapping/sub.cpp)
 set_target_properties(pybind_name_mapping PROPERTIES
diff --git a/documentation/test_python/layout_search_binary/index.html b/documentation/test_python/layout_search_binary/index.html
new file mode 100644 (file)
index 0000000..89163ee
--- /dev/null
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+          <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+        </svg></a>
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="1">
+            <li class="m-show-m"><a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+              <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+            </svg></a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          My Python Project
+        </h1>
+      </div>
+    </div>
+  </div>
+</article></main>
+<div class="m-doc-search" id="search">
+  <a href="#!" onclick="return hideSearch()"></a>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-m-8 m-push-m-2">
+        <div class="m-doc-search-header m-text m-small">
+          <div><span class="m-label m-default">Tab</span> / <span class="m-label m-default">T</span> to search, <span class="m-label m-default">Esc</span> to close</div>
+          <div id="search-symbolcount">&hellip;</div>
+        </div>
+        <div class="m-doc-search-content">
+          <form>
+            <input type="search" name="q" id="search-input" placeholder="Loading &hellip;" disabled="disabled" autofocus="autofocus" autocomplete="off" spellcheck="false" />
+          </form>
+          <noscript class="m-text m-danger m-text-center">Unlike everything else in the docs, the search functionality <em>requires</em> JavaScript.</noscript>
+          <div id="search-help" class="m-text m-dim m-text-center">
+            <p class="m-noindent">Search for modules, classes, functions and other
+            symbols. You can omit any prefix from the symbol path; adding a <code>.</code>
+            suffix lists all members of given symbol.</p>
+            <p class="m-noindent">Use <span class="m-label m-dim">&darr;</span>
+            / <span class="m-label m-dim">&uarr;</span> to navigate through the list,
+            <span class="m-label m-dim">Enter</span> to go.
+            <span class="m-label m-dim">Tab</span> autocompletes common prefix, you can
+            copy a link to the result using <span class="m-label m-dim">⌘</span>
+            <span class="m-label m-dim">L</span> while <span class="m-label m-dim">⌘</span>
+            <span class="m-label m-dim">M</span> produces a Markdown link.</p>
+          </div>
+          <div id="search-notfound" class="m-text m-warning m-text-center">Sorry, nothing was found.</div>
+          <ul id="search-results"></ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+<script src="search.js"></script>
+<script>
+  Search.download(window.location.pathname.substr(0, window.location.pathname.lastIndexOf('/') + 1));
+</script>
+</body>
+</html>
diff --git a/documentation/test_python/layout_search_open_search/index.html b/documentation/test_python/layout_search_open_search/index.html
new file mode 100644 (file)
index 0000000..509f944
--- /dev/null
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <link rel="icon" href="favicon-dark.png" type="image/png" />
+  <link rel="search" type="application/opensearchdescription+xml" href="opensearch.xml" title="Search My Python Project documentation" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+          <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+        </svg></a>
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="1">
+            <li class="m-show-m"><a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+              <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+            </svg></a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          My Python Project
+        </h1>
+      </div>
+    </div>
+  </div>
+</article></main>
+<div class="m-doc-search" id="search">
+  <a href="#!" onclick="return hideSearch()"></a>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-m-8 m-push-m-2">
+        <div class="m-doc-search-header m-text m-small">
+          <div><span class="m-label m-default">Tab</span> / <span class="m-label m-default">T</span> to search, <span class="m-label m-default">Esc</span> to close</div>
+          <div id="search-symbolcount">&hellip;</div>
+        </div>
+        <div class="m-doc-search-content">
+          <form action="http://localhost:8000#search">
+            <input type="search" name="q" id="search-input" placeholder="Loading &hellip;" disabled="disabled" autofocus="autofocus" autocomplete="off" spellcheck="false" />
+          </form>
+          <noscript class="m-text m-danger m-text-center">Unlike everything else in the docs, the search functionality <em>requires</em> JavaScript.</noscript>
+          <div id="search-help" class="m-text m-dim m-text-center">
+            <p>Right-click to add a search engine.</p>
+          </div>
+          <div id="search-notfound" class="m-text m-warning m-text-center">Sorry, nothing was found.</div>
+          <ul id="search-results"></ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+<script src="search.js"></script>
+<script src="searchdata-v1.js" async="async"></script>
+</body>
+</html>
diff --git a/documentation/test_python/layout_search_open_search/opensearch.xml b/documentation/test_python/layout_search_open_search/opensearch.xml
new file mode 100644 (file)
index 0000000..7abfef4
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
+  <ShortName>My Python Project</ShortName>
+  <Description>Search My Python Project documentation</Description>
+  <Image type="image/png">http://localhost:8000/favicon-dark.png</Image>
+  <Url type="text/html" template="http://localhost:8000?q={searchTerms}#search"/>
+</OpenSearchDescription>
diff --git a/documentation/test_python/search/search/__init__.py b/documentation/test_python/search/search/__init__.py
new file mode 100644 (file)
index 0000000..4f4c193
--- /dev/null
@@ -0,0 +1,21 @@
+import enum
+
+from . import sub, pybind
+
+class Foo:
+    def a_method(self):
+        pass
+
+    @property
+    def a_property(self):
+        pass
+
+    class Enum(enum.Enum):
+        A_VALUE = 1
+        ANOTHER = 2
+
+def a_function():
+    pass
+
+def func_with_params(a, b):
+    pass
diff --git a/documentation/test_python/search/search/pybind.cpp b/documentation/test_python/search/search/pybind.cpp
new file mode 100644 (file)
index 0000000..25bf02e
--- /dev/null
@@ -0,0 +1,27 @@
+#include <pybind11/pybind11.h>
+
+namespace py = pybind11;
+
+namespace {
+
+struct Foo {
+    void method() {}
+    void methodWithParams(int, float) {}
+};
+
+void overloadedFunction(int b, float) {}
+void overloadedFunction(int b) {}
+void overloadedFunction(int b, Foo) {}
+
+}
+
+PYBIND11_MODULE(pybind, m) {
+    py::class_<Foo>{m, "Foo"}
+        .def("method", &Foo::method)
+        .def("method_with_params", &Foo::methodWithParams, py::arg("first"), py::arg("second"));
+
+    m
+        .def("overloaded_function", static_cast<void(*)(int, float)>(&overloadedFunction))
+        .def("overloaded_function", static_cast<void(*)(int)>(&overloadedFunction))
+        .def("overloaded_function", static_cast<void(*)(int, Foo)>(&overloadedFunction));
+}
diff --git a/documentation/test_python/search/search/sub.py b/documentation/test_python/search/search/sub.py
new file mode 100644 (file)
index 0000000..3c9912c
--- /dev/null
@@ -0,0 +1 @@
+DATA_IN_A_SUBMODULE = "hello"
diff --git a/documentation/test_python/search_long_suffix_length/search_long_suffix_length.cpp b/documentation/test_python/search_long_suffix_length/search_long_suffix_length.cpp
new file mode 100644 (file)
index 0000000..8cc6e1f
--- /dev/null
@@ -0,0 +1,14 @@
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+namespace py = pybind11;
+
+namespace {
+
+void manyParameters(std::tuple<int, float, std::string, std::vector<std::pair<int, int>>> a, std::tuple<int, float, std::string, std::vector<std::pair<int, int>>> b, std::tuple<int, float, std::string, std::vector<std::pair<int, int>>> c) {}
+
+}
+
+PYBIND11_MODULE(search_long_suffix_length, m) {
+    m.def("many_parameters", &manyParameters);
+}
index 7a7278ae5197d200c0d3cd1dcc2f8b45e7406357..7324c5ae458fc0cd35e0f056c6c16bc4890b5fda 100644 (file)
@@ -24,6 +24,7 @@
 
 import os
 
+from _search import searchdata_filename, searchdata_filename_b85
 from . import BaseTestCase
 
 class Layout(BaseTestCase):
@@ -58,4 +59,26 @@ class Layout(BaseTestCase):
         self.assertEqual(*self.actual_expected_contents('index.html'))
         self.assertTrue(os.path.exists(os.path.join(self.path, 'output/m-dark+documentation.compiled.css')))
         self.assertTrue(os.path.exists(os.path.join(self.path, 'output/favicon-light.png')))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/search.js')))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output', searchdata_filename_b85)))
         self.assertTrue(os.path.exists(os.path.join(self.path, 'output/sitemap.xml')))
+
+class SearchBinary(BaseTestCase):
+    def test(self):
+        self.run_python({
+            'SEARCH_DISABLED': False,
+            'SEARCH_DOWNLOAD_BINARY': True
+        })
+        self.assertEqual(*self.actual_expected_contents('index.html'))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output', searchdata_filename)))
+
+class SearchOpenSearch(BaseTestCase):
+    def test(self):
+        self.run_python({
+            'FAVICON': 'favicon-dark.png',
+            'SEARCH_DISABLED': False,
+            'SEARCH_BASE_URL': 'http://localhost:8000',
+            'SEARCH_HELP': "Right-click to add a search engine."
+        })
+        self.assertEqual(*self.actual_expected_contents('index.html'))
+        self.assertEqual(*self.actual_expected_contents('opensearch.xml'))
diff --git a/documentation/test_python/test_search.py b/documentation/test_python/test_search.py
new file mode 100644 (file)
index 0000000..16424d7
--- /dev/null
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+import os
+
+from _search import searchdata_filename, pretty_print
+from python import EntryType
+
+from test_python import BaseInspectTestCase
+
+class Search(BaseInspectTestCase):
+    def test(self):
+        self.run_python({
+            'SEARCH_DISABLED': False,
+            'SEARCH_DOWNLOAD_BINARY': True,
+            'PYBIND11_COMPATIBILITY': True
+        })
+
+        with open(os.path.join(self.path, 'output', searchdata_filename), 'rb') as f:
+            serialized = f.read()
+            search_data_pretty = pretty_print(serialized, entryTypeClass=EntryType)[0]
+        #print(search_data_pretty)
+        self.assertEqual(len(serialized), 1918)
+        self.assertEqual(search_data_pretty, """
+18 symbols
+search [11]
+||    .$
+||     foo [6]
+||     || .$
+||     ||  enum [0]
+||     ||  |   .$
+||     ||  |    a_value [1]
+||     ||  |     nother [2]
+||     ||  a_method [3]
+||     ||  | |     ($
+||     ||  | |      ) [4]
+||     ||  | property [5]
+||     |unc_with_params [9]
+||     ||              ($
+||     ||               ) [10]
+||     a_function [7]
+||     |         ($
+||     |          ) [8]
+||     pybind [23]
+||     |     .$
+||     |      foo [16]
+||     |      |  .$
+||     |      |   method [12]
+||     |      |         ($
+||     |      |          ) [13]
+||     |      |         _with_params [14]
+||     |      |         |           ($
+||     |      |         |            ) [15]
+||     |      overloaded_function [19, 21, 17]
+||     |      |                  ($
+||     |      |                   ) [20, 22, 18]
+||     sub [25]
+||     |  .$
+||     |   data_in_a_submodule [24]
+|ub [25]
+|| .$
+||  data_in_a_submodule [24]
+foo [6, 16]
+|| .$
+||  enum [0]
+||  |   .$
+||  |    a_value [1]
+||  |     nother [2]
+||  a_method [3]
+||  | |     ($
+||  | |      ) [4]
+||  | property [5]
+||  method [12]
+||  |     ($
+||  |      ) [13]
+||  |     _with_params [14]
+||  |     |           ($
+||  |     |            ) [15]
+|unc_with_params [9]
+||              ($
+||               ) [10]
+enum [0]
+|   .$
+|    a_value [1]
+|     nother [2]
+a_value [1]
+||method [3]
+|||     ($
+|||      ) [4]
+||property [5]
+||function [7]
+|||       ($
+|||        ) [8]
+|nother [2]
+pybind [23]
+|     .$
+|      foo [16]
+|      |  .$
+|      |   method [12]
+|      |         ($
+|      |          ) [13]
+|      |         _with_params [14]
+|      |         |           ($
+|      |         |            ) [15]
+|      overloaded_function [19, 21, 17]
+|      |                  ($
+|      |                   ) [20, 22, 18]
+method [12]
+|     ($
+|      ) [13]
+|     _with_params [14]
+|     |           ($
+|     |            ) [15]
+overloaded_function [19, 21, 17]
+|                  ($
+|                   ) [20, 22, 18]
+data_in_a_submodule [24]
+0: .Enum [prefix=6[:15], type=ENUM] -> #Enum
+1: .A_VALUE [prefix=0[:20], type=ENUM_VALUE] -> -A_VALUE
+2: .ANOTHER [prefix=0[:20], type=ENUM_VALUE] -> -ANOTHER
+3: .a_method() [prefix=6[:15], suffix_length=2, type=FUNCTION] -> #a_method
+4:  [prefix=3[:24], type=FUNCTION] ->
+5: .a_property [prefix=6[:15], type=PROPERTY] -> #a_property
+6: .Foo [prefix=11[:7], type=CLASS] -> Foo.html
+7: .a_function() [prefix=11[:11], suffix_length=2, type=FUNCTION] -> #a_function
+8:  [prefix=7[:22], type=FUNCTION] ->
+9: .func_with_params() [prefix=11[:11], suffix_length=2, type=FUNCTION] -> #func_with_params
+10:  [prefix=9[:28], type=FUNCTION] ->
+11: search [type=MODULE] -> search.html
+12: .method(self) [prefix=16[:22], suffix_length=6, type=FUNCTION] -> #method-6eef6
+13:  [prefix=12[:35], suffix_length=4, type=FUNCTION] ->
+14: .method_with_params(self, first: int, second: float) [prefix=16[:22], suffix_length=33, type=FUNCTION] -> #method_with_params-27269
+15:  [prefix=14[:47], suffix_length=31, type=FUNCTION] ->
+16: .Foo [prefix=23[:14], type=CLASS] -> Foo.html
+17: .overloaded_function(arg0: int, arg1: float) [prefix=23[:18], suffix_length=24, type=FUNCTION] -> #overloaded_function-8f19c
+18:  [prefix=17[:44], suffix_length=22, type=FUNCTION] ->
+19: .overloaded_function(arg0: int) [prefix=23[:18], suffix_length=11, type=FUNCTION] -> #overloaded_function-46f8a
+20:  [prefix=19[:44], suffix_length=9, type=FUNCTION] ->
+21: .overloaded_function(arg0: int, arg1: Foo) [prefix=23[:18], suffix_length=22, type=FUNCTION] -> #overloaded_function-0cacd
+22:  [prefix=21[:44], suffix_length=20, type=FUNCTION] ->
+23: .pybind [prefix=11[:7], type=MODULE] -> pybind.html
+24: .DATA_IN_A_SUBMODULE [prefix=25[:15], type=DATA] -> #DATA_IN_A_SUBMODULE
+25: .sub [prefix=11[:7], type=MODULE] -> sub.html
+(EntryType.PAGE, CssClass.SUCCESS, 'page'),
+(EntryType.MODULE, CssClass.PRIMARY, 'module'),
+(EntryType.CLASS, CssClass.PRIMARY, 'class'),
+(EntryType.FUNCTION, CssClass.INFO, 'func'),
+(EntryType.PROPERTY, CssClass.WARNING, 'property'),
+(EntryType.ENUM, CssClass.PRIMARY, 'enum'),
+(EntryType.ENUM_VALUE, CssClass.DEFAULT, 'enum val'),
+(EntryType.DATA, CssClass.DEFAULT, 'data')
+""".strip())
+
+class LongSuffixLength(BaseInspectTestCase):
+    def test(self):
+        self.run_python({
+            'SEARCH_DISABLED': False,
+            'SEARCH_DOWNLOAD_BINARY': True,
+            'PYBIND11_COMPATIBILITY': True
+        })
+
+        with open(os.path.join(self.path, 'output', searchdata_filename), 'rb') as f:
+            serialized = f.read()
+            search_data_pretty = pretty_print(serialized, entryTypeClass=EntryType)[0]
+        #print(search_data_pretty)
+        self.assertEqual(len(serialized), 521)
+        # The parameters get cut off with an ellipsis
+        self.assertEqual(search_data_pretty, """
+2 symbols
+search_long_suffix_length [2]
+|                        .$
+|                         many_parameters [0]
+|                                        ($
+|                                         ) [1]
+many_parameters [0]
+|              ($
+|               ) [1]
+0: .many_parameters(arg0: Tuple[int, float, str, List[Tuple[int, int…) [prefix=2[:30], suffix_length=53, type=FUNCTION] -> #many_parameters-5ce5b
+1:  [prefix=0[:52], suffix_length=51, type=FUNCTION] ->
+2: search_long_suffix_length [type=MODULE] -> search_long_suffix_length.html
+(EntryType.PAGE, CssClass.SUCCESS, 'page'),
+(EntryType.MODULE, CssClass.PRIMARY, 'module'),
+(EntryType.CLASS, CssClass.PRIMARY, 'class'),
+(EntryType.FUNCTION, CssClass.INFO, 'func'),
+(EntryType.PROPERTY, CssClass.WARNING, 'property'),
+(EntryType.ENUM, CssClass.PRIMARY, 'enum'),
+(EntryType.ENUM_VALUE, CssClass.DEFAULT, 'enum val'),
+(EntryType.DATA, CssClass.DEFAULT, 'data')
+""".strip())