| # Rustdoc search | |
| Rustdoc Search is two programs: `search_index.rs` and `search.js`. | |
| The first generates a nasty JSON file with a full list of items and function signatures | |
| in the crates in the doc bundle, and the second reads | |
| it, turns it into some in-memory structures, and scans them linearly to search. | |
| ## Search index format | |
| `search.js` calls this Raw, because it turns it into a more normal object tree after loading it. | |
| For space savings, it's also written without newlines or spaces. | |
| ```json | |
| [ | |
| [ "crate_name", { | |
| // name | |
| "n": ["function_name", "Data"], | |
| // type | |
| "t": "HF", | |
| // parent module | |
| "q": [[0, "crate_name"]], | |
| // parent type | |
| "i": [2, 0], | |
| // type dictionary | |
| "p": [[1, "i32"], [1, "str"], [5, "Data", 0]], | |
| // function signature | |
| "f": "{{gb}{d}}`", // [[3, 1], [2]] | |
| // impl disambiguator | |
| "b": [], | |
| // deprecated flag | |
| "c": "OjAAAAAAAAA=", // empty bitmap | |
| // empty description flag | |
| "e": "OjAAAAAAAAA=", // empty bitmap | |
| // aliases | |
| "a": [["get_name", 0]], | |
| // description shards | |
| "D": "g", // 3 | |
| // inlined re-exports | |
| "r": [], | |
| }] | |
| ] | |
| ``` | |
| [`src/librustdoc/html/static/js/rustdoc.d.ts`] defines an actual schema in a TypeScript `type`. | |
| | Key | Name | Description | | |
| | --- | -------------------- | ------------ | | |
| | `n` | Names | Item names | | |
| | `t` | Item Type | One-char item type code | | |
| | `q` | Parent module | `Map<index, path>` | | |
| | `i` | Parent type | list of indexes | | |
| | `f` | Function signature | [encoded](#i-f-and-p) | | |
| | `b` | Impl disambiguator | `Map<index, string>` | | |
| | `c` | Deprecation flag | [roaring bitmap](#roaring-bitmaps) | | |
| | `e` | Description is empty | [roaring bitmap](#roaring-bitmaps) | | |
| | `p` | Type dictionary | `[[item type, path]]` | | |
| | `a` | Alias | `Map<string, index>` | | |
| | `D` | description shards | [encoded](#how-descriptions-are-stored) | | |
| The above index defines a crate called `crate_name` | |
| with a free function called `function_name` and a struct called `Data`, | |
| with the type signature `Data, i32 -> str`, | |
| and an alias, `get_name`, that equivalently refers to `function_name`. | |
| [`src/librustdoc/html/static/js/rustdoc.d.ts`]: https://github.com/rust-lang/rust/blob/2f92f050e83bf3312ce4ba73c31fe843ad3cbc60/src/librustdoc/html/static/js/rustdoc.d.ts#L344-L390 | |
| The search index needs to fit the needs of the `rustdoc` compiler, | |
| the `search.js` frontend, | |
| and also be compact and fast to decode. | |
| It makes a lot of compromises: | |
| * The `rustdoc` compiler runs on one crate at a time, | |
| so each crate has an essentially separate search index. | |
| It [merges] them by having each crate on one line and looking at the first quoted string. | |
| * Names in the search index are given in their original case and with underscores. | |
| When the search index is loaded, | |
| `search.js` stores the original names for display, | |
| but also folds them to lowercase and strips underscores for search. | |
| You'll see them called `normalized`. | |
| * The `f` array stores types as offsets into the `p` array. | |
| These types might actually be from another crate, | |
| so `search.js` has to turn the numbers into names and then | |
| back into numbers to deduplicate them if multiple crates in the | |
| same index mention the same types. | |
| * It's a JSON file, but not designed to be human-readable. | |
| Browsers already include an optimized JSON decoder, | |
| so this saves on `search.js` code and performs better for small crates, | |
| but instead of using objects like normal JSON formats do, | |
| it tries to put data of the same type next to each other | |
| so that the sliding window used by [DEFLATE] can find redundancies. | |
| Where `search.js` does its own compression, | |
| it's designed to save memory when the file is finally loaded, | |
| not just size on disk or network transfer. | |
| [merges]: https://github.com/rust-lang/rust/blob/79b710c13968a1a48d94431d024d2b1677940866/src/librustdoc/html/render/write_shared.rs#L151-L164 | |
| [DEFLATE]: https://en.wikipedia.org/wiki/Deflate | |
| ### Parallel arrays and indexed maps | |
| Abstractly, Rustdoc Search data is a table, stored in column-major form. | |
| Most data in the index represents a set of parallel arrays | |
| (the "columns") which refer to the same data if they're at the same position. | |
| For example, | |
| the above search index can be turned into this table: | |
| | | n | t | [d] | q | i | f | b | c | | |
| |---|---|---|-----|---|---|---|---|---| | |
| | 0 | `crate_name` | `D` | Documentation | NULL | 0 | NULL | NULL | 0 | | |
| | 1 | `function_name` | `H` | This function gets the name of an integer with Data | `crate_name` | 2 | `{{gb}{d}}` | NULL | 0 | | |
| | 2 | `Data` | `F` | The data struct | `crate_name` | 0 | `` ` `` | NULL | 0 | | |
| [d]: #how-descriptions-are-stored | |
| The crate row is implied in most columns, since its type is known (it's a crate), | |
| it can't have a parent (crates form the root of the module tree), | |
| its name is specified as the map key, | |
| and function-specific data like the impl disambiguator can't apply either. | |
| However, it can still have a description and it can still be deprecated. | |
| The crate, therefore, has a primary key of `0`. | |
| The above code doesn't use `c`, which holds deprecated indices, | |
| or `b`, which maps indices to strings. | |
| If `crate_name::function_name` used both, it might look like this. | |
| ```json | |
| "b": [[0, "impl-Foo-for-Bar"]], | |
| "c": "OjAAAAEAAAAAAAIAEAAAABUAbgZYCQ==", | |
| ``` | |
| This attaches a disambiguator to index 1 and marks it deprecated. | |
| The advantage of this layout is that these APIs often have implicit structure | |
| that DEFLATE can take advantage of, | |
| but that rustdoc can't assume. | |
| Like how names are usually CamelCase or snake_case, | |
| but descriptions aren't. | |
| It also makes it easier to use a sparse data for things like boolean flags. | |
| `q` is a Map from *the first applicable* ID to a parent module path. | |
| This is a weird trick, but it makes more sense in pseudo-code: | |
| ```rust | |
| let mut parent_module = ""; | |
| for (i, entry) in search_index.iter().enumerate() { | |
| if q.contains(i) { | |
| parent_module = q.get(i); | |
| } | |
| // ... do other stuff with `entry` ... | |
| } | |
| ``` | |
| This is valid because everything has a parent module (even if it's just the crate itself), | |
| and is easy to assemble because the rustdoc generator sorts by path before serializing. | |
| Doing this allows rustdoc to not only make the search index smaller, | |
| but reuse the same string representing the parent path across multiple in-memory items. | |
| ### Representing sparse columns | |
| #### VLQ Hex | |
| This format is, as far as I know, used nowhere other than rustdoc. | |
| It follows this grammar: | |
| ```ebnf | |
| VLQHex = { VHItem | VHBackref } | |
| VHItem = VHNumber | ( '{', {VHItem}, '}' ) | |
| VHNumber = { '@' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' }, ( '`' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k ' | 'l' | 'm' | 'n' | 'o' ) | |
| VHBackref = ( '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | ':' | ';' | '<' | '=' | '>' | '?' ) | |
| ``` | |
| A VHNumber is a variable-length, self-terminating base16 number | |
| (terminated because the last hexit is lowercase while all others are uppercase). | |
| The sign bit is represented using [zig-zag encoding]. | |
| This alphabet is chosen because the characters can be turned into hexits by | |
| masking off the last four bits of the ASCII encoding. | |
| A major feature of this encoding, as with all of the "compression" done in rustdoc, | |
| is that it can remain in its compressed format *even in memory at runtime*. | |
| This is why `HBackref` is only used at the top level, | |
| and why we don't just use [Flate] for everything: the decoder in search.js | |
| will reuse the entire decoded object whenever a backref is seen, | |
| saving decode work and memory. | |
| [zig-zag encoding]: https://en.wikipedia.org/wiki/Variable-length_quantity#Zigzag_encoding | |
| [Flate]: https://en.wikipedia.org/wiki/Deflate | |
| #### Roaring Bitmaps | |
| Flag-style data, such as deprecation and empty descriptions, | |
| are stored using the [standard Roaring Bitmap serialization format with runs]. | |
| The data is then base64 encoded when writing it. | |
| As a brief overview: a roaring bitmap is a chunked array of bits, | |
| described in [this paper]. | |
| A chunk can either be a list of integers, a bitfield, or a list of runs. | |
| In any case, the search engine has to base64 decode it, | |
| and read the chunk index itself, | |
| but the payload data stays as-is. | |
| All roaring bitmaps in rustdoc currently store a flag for each item index. | |
| The crate is item 0, all others start at 1. | |
| [standard Roaring Bitmap serialization format with runs]: https://github.com/RoaringBitmap/RoaringFormatSpec | |
| [this paper]: https://arxiv.org/pdf/1603.06549.pdf | |
| ### How descriptions are stored | |
| The largest amount of data, | |
| and the main thing Rustdoc Search deals with that isn't | |
| actually used for searching, is descriptions. | |
| In a SERP table, this is what appears on the rightmost column. | |
| > | item type | item path | ***description*** (this part) | | |
| > | --------- | --------------------- | --------------------------------------------------- | | |
| > | function | my_crate::my_function | This function gets the name of an integer with Data | | |
| When someone runs a search in rustdoc for the first time, their browser will | |
| work through a "sandwich workload" of three steps: | |
| 1. Download the search-index.js and search.js files (a network bottleneck). | |
| 2. Perform the actual search (a CPU and memory bandwidth bottleneck). | |
| 3. Download the description data (another network bottleneck). | |
| Reducing the amount of data downloaded here will almost always increase latency, | |
| by delaying the decision of what to download behind other work and/or adding | |
| data dependencies where something can't be downloaded without first downloading something else. | |
| In this case, we can't start downloading descriptions until | |
| after the search is done, because that's what allows it to decide *which* | |
| descriptions to download (it needs to sort the results then truncate to 200). | |
| To do this, two columns are stored in the search index, building on both | |
| Roaring Bitmaps and on VLQ Hex. | |
| * `e` is an index of **e**mpty descriptions. | |
| It's a [roaring bitmap] of each item (the crate itself is item 0, the rest start at 1). | |
| * `D` is a shard list, stored in [VLQ hex] as flat list of integers. | |
| Each integer gives you the number of descriptions in the shard. | |
| As the decoder walks the index, it checks if the description is empty. | |
| if it's not, then it's in the "current" shard. | |
| When all items are exhausted, it goes on to the next shard. | |
| Inside each shard is a newline-delimited list of descriptions, | |
| wrapped in a JSONP-style function call. | |
| [roaring bitmap]: #roaring-bitmaps | |
| [VLQ hex]: #vlq-hex | |
| ### `i`, `f`, and `p` | |
| `i` and `f` both index into `p`, the array of parent items. | |
| `i` is just a one-indexed number | |
| (not zero-indexed because `0` is used for items that have no parent item). | |
| It's different from `q` because `q` represents the parent *module or crate*, | |
| which everything has, | |
| while `i`/`q` are used for *type and trait-associated items* like methods. | |
| `f`, the function signatures, use a [VLQ hex] tree. | |
| A number is either a one-indexed reference into `p`, | |
| a negative number representing a generic, | |
| or zero for null. | |
| (the internal object representation also uses negative numbers, | |
| even after decoding, | |
| to represent generics). | |
| For example, `{{gb}{d}}` is equivalent to the json `[[3, 1], [2]]`. | |
| Because of zigzag encoding, `` ` `` is +0, `a` is -0 (which is not used), | |
| `b` is +1, and `c` is -1. | |
| ## Searching by name | |
| Searching by name works by looping through the search index and running these functions on each: | |
| * [`editDistance`] is always used to determine a match | |
| (unless quotes are specified, which would use simple equality instead). | |
| It computes the number of swaps, inserts, and removes needed to turn | |
| the query name into the entry name. | |
| For example, `foo` has zero distance from itself, | |
| but a distance of 1 from `ofo` (one swap) and `foob` (one insert). | |
| It is checked against an heuristic threshold, and then, | |
| if it is within that threshold, the distance is stored for ranking. | |
| * [`String.prototype.indexOf`] is always used to determine a match. | |
| If it returns anything other than -1, the result is added, | |
| even if `editDistance` exceeds its threshold, | |
| and the index is stored for ranking. | |
| * [`checkPath`] is used if, and only if, a parent path is specified in the query. | |
| For example, `vec` has no parent path, but `vec::vec` does. | |
| Within checkPath, editDistance and indexOf are used, | |
| and the path query has its own heuristic threshold, too. | |
| If it's not within the threshold, the entry is rejected, | |
| even if the first two pass. | |
| If it's within the threshold, the path distance is stored for ranking. | |
| * [`checkType`] is used only if there's a type filter, | |
| like the struct in `struct:vec`. | |
| If it fails, | |
| the entry is rejected. | |
| If all four criteria pass (plus the crate filter, which isn't technically part of the query), | |
| the results are sorted by [`sortResults`]. | |
| [`editDistance`]: https://github.com/rust-lang/rust/blob/79b710c13968a1a48d94431d024d2b1677940866/src/librustdoc/html/static/js/search.js#L137 | |
| [`String.prototype.indexOf`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf | |
| [`checkPath`]: https://github.com/rust-lang/rust/blob/79b710c13968a1a48d94431d024d2b1677940866/src/librustdoc/html/static/js/search.js#L1814 | |
| [`checkType`]: https://github.com/rust-lang/rust/blob/79b710c13968a1a48d94431d024d2b1677940866/src/librustdoc/html/static/js/search.js#L1787 | |
| [`sortResults`]: https://github.com/rust-lang/rust/blob/79b710c13968a1a48d94431d024d2b1677940866/src/librustdoc/html/static/js/search.js#L1229 | |
| ## Searching by type | |
| Searching by type can be divided into two phases, | |
| and the second phase has two sub-phases. | |
| * Turn names in the query into numbers. | |
| * Loop over each entry in the search index: | |
| * Quick rejection using a bloom filter. | |
| * Slow rejection using a recursive type unification algorithm. | |
| In the names->numbers phase, if the query has only one name in it, | |
| the editDistance function is used to find a near match if the exact match fails, | |
| but if there's multiple items in the query, | |
| non-matching items are treated as generics instead. | |
| This means `hahsmap` will match hashmap on its own, but `hahsmap, u32` | |
| is going to match the same things `T, u32` matches | |
| (though rustdoc will detect this particular problem and warn about it). | |
| Then, when actually looping over each item, | |
| the bloom filter will probably reject entries that don't have every type mentioned in the query. | |
| For example, the bloom query allows a query of `i32 -> u32` to match | |
| a function with the type `i32, u32 -> bool`, | |
| but unification will reject it later. | |
| The unification filter ensures that: | |
| * Bag semantics are respected. | |
| If your query says `i32, i32`, | |
| then the function has to mention *two* i32s, not just one. | |
| * Nesting semantics are respected. | |
| If your query says `vec<option>`, | |
| then `vec<option<i32>>` is fine, but `option<vec<i32>>` *is not* a match. | |
| * The division between return type and parameter is respected. | |
| `i32 -> u32` and `u32 -> i32` are completely different. | |
| The bloom filter checks none of these things, | |
| and, on top of that, can have false positives. | |
| But it's fast and uses very little memory, so the bloom filter helps. | |
| ## Re-exports | |
| [Re-export inlining] allows the same item to be found by multiple names. | |
| Search supports this by giving the same item multiple entries and tracking a canonical path | |
| for any items where that differs from the given path. | |
| For example, this sample index has a single struct exported from two paths: | |
| ```json | |
| [ | |
| [ "crate_name", { | |
| "doc": "Documentation", | |
| "n": ["Data", "Data"], | |
| "t": "FF", | |
| "d": ["The data struct", "The data struct"], | |
| "q": [[0, "crate_name"], [1, "crate_name::submodule"]], | |
| "i": [0, 0], | |
| "p": [], | |
| "f": "``", | |
| "b": [], | |
| "c": [], | |
| "a": [], | |
| "r": [[0, 1]], | |
| }] | |
| ] | |
| ``` | |
| The important part of this example is the `r` array, | |
| which indicates that path entry 1 in the `q` array is the canonical path for item 0. | |
| That is, `crate_name::Data` has a canonical path of `crate_name::submodule::Data`. | |
| This might sound like a strange design, since it has the duplicate data. | |
| It's done that way because inlining can happen across crates, | |
| which are compiled separately and might not all be present in the docs. | |
| ```json | |
| [ | |
| [ "crate_name", ... ], | |
| [ "crate_name_2", { "q": [[0, "crate_name::submodule"], [5, "core::option"]], ... }] | |
| ] | |
| ``` | |
| In the above example, a canonical path actually comes from a dependency, | |
| and another one comes from an inlined standard library item: | |
| the canonical path isn't even in the index! | |
| The canonical path might also be private. | |
| In either case, it's never shown to the user, and is only used for deduplication. | |
| Associated types, like methods, store them differently. | |
| These types are connected with an entry in `p` (their "parent") | |
| and each one has an optional third tuple element: | |
| "p": [[5, "Data", 0, 1]] | |
| That's: | |
| - 5: It's a struct | |
| - "Data": Its name | |
| - 0: Its display path, "crate_name" | |
| - 1: Its canonical path, "crate_name::submodule" | |
| In both cases, the canonical path might not be public at all, | |
| or it might be from another crate that isn't in the docs, | |
| so it's never shown to the user, but is used for deduplication. | |
| [Re-export inlining]: https://doc.rust-lang.org/nightly/rustdoc/write-documentation/re-exports.html | |
| ## Testing the search engine | |
| While the generated UI is tested using `rustdoc-gui` tests, the | |
| primary way the search engine is tested is the `rustdoc-js` and `rustdoc-js-std` tests. | |
| They run in NodeJS. | |
| A `rustdoc-js` test has a `.rs` and `.js` file, with the same name. | |
| The `.rs` file specifies the hypothetical library crate to run | |
| the searches on (make sure you mark anything you need to find as `pub`). | |
| The `.js` file specifies the actual searches. | |
| The `rustdoc-js-std` tests are the same, but don't require an `.rs` | |
| file, since they use the standard library. | |
| The `.js` file is like a module (except the loader takes care of `exports` for you). | |
| It uses these variables: | |
| | Name | Type | Description | |
| | -------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | |
| | `FILTER_CRATE` | `string` | Only include results from the given crate. In the GUI, this is the "Results in <kbd>crate</kbd>" drop-down menu. | |
| | `EXPECTED` | `[ResultsTable]\|ResultsTable` | List of tests to run, specifying what the hypothetical user types into the search box and sees in the tabs | |
| | `PARSED` | `[ParsedQuery]\|ParsedQuery` | List of parser tests to run, without running an actual search | |
| `FILTER_CRATE` can be left out (equivalent to searching "all crates"), but you | |
| have to specify `EXPECTED` or `PARSED`. | |
| By default, the test fails if any of the results specified in the test case are | |
| not found after running the search, or if the results found after running the | |
| search don't appear in the same order that they do in the test. | |
| The actual search results may, however, include results that aren't in the test. | |
| To override this, specify any of the following magic comments. | |
| Put them on their own line, without indenting. | |
| * `// exact-check`: If search results appear that aren't part of the test case, | |
| then fail. | |
| * `// ignore-order`: Allow search results to appear in any order. | |
| * `// should-fail`: Used to write negative tests. | |
| Standard library tests usually shouldn't specify `// exact-check`, since we | |
| want the libs team to be able to add new items without causing unrelated | |
| tests to fail, but standalone tests will use it more often. | |
| The `ResultsTable` and `ParsedQuery` types are specified in | |
| [`rustdoc.d.ts`](https://github.com/rust-lang/rust/blob/HEAD/src/librustdoc/html/static/js/rustdoc.d.ts). | |
| For example, imagine we needed to fix a bug where a function named `constructor` couldn't be found. | |
| To do this, write two files: | |
| ```rust | |
| // tests/rustdoc-js/constructor_search.rs | |
| // The test case needs to find this result. | |
| pub fn constructor(_input: &str) -> i32 { 1 } | |
| ``` | |
| ```js | |
| // tests/rustdoc-js/constructor_search.js | |
| // exact-check | |
| // Since this test runs against its own crate, | |
| // new items should not appear in the search results. | |
| const EXPECTED = [ | |
| // This first test targets name-based search. | |
| { | |
| query: "constructor", | |
| others: [ | |
| { path: "constructor_search", name: "constructor" }, | |
| ], | |
| in_args: [], | |
| returned: [], | |
| }, | |
| // This test targets the second tab. | |
| { | |
| query: "str", | |
| others: [], | |
| in_args: [ | |
| { path: "constructor_search", name: "constructor" }, | |
| ], | |
| returned: [], | |
| }, | |
| // This test targets the third tab. | |
| { | |
| query: "i32", | |
| others: [], | |
| in_args: [], | |
| returned: [ | |
| { path: "constructor_search", name: "constructor" }, | |
| ], | |
| }, | |
| // This test targets advanced type-driven search. | |
| { | |
| query: "str -> i32", | |
| others: [ | |
| { path: "constructor_search", name: "constructor" }, | |
| ], | |
| in_args: [], | |
| returned: [], | |
| }, | |
| ] | |
| ``` | |
| If the [`//@ revisions`] directive is used, the JS file will | |
| have access to a variable called `REVISION`. | |
| ```js | |
| const EXPECTED = [ | |
| // This first test targets name-based search. | |
| { | |
| query: "constructor", | |
| others: REVISION === "has_constructor" ? | |
| [ | |
| { path: "constructor_search", name: "constructor" }, | |
| ] : | |
| [], | |
| in_args: [], | |
| returned: [], | |
| }, | |
| ]; | |
| ``` | |
| [`//@ revisions`]: ../tests/compiletest.md#revisions |