Skip to main content

sphinx_ultra/rst/
mod.rs

1//! Recursive-descent RST parser with docutils-0.22.4 fidelity (M2 wave 1:
2//! block grammar only — the inline parser arrives in wave 2).
3//!
4//! Fidelity contract: output `pformat()` is byte-identical to
5//! `docutils.parsers.rst.Parser` parse-layer output for the construct set in
6//! `tests/fixtures/doctree_differential.json`. Transforms (doctitle
7//! promotion, target propagation, transition hoisting, message filtering)
8//! are explicitly NOT applied here; they arrive as separate components in
9//! later waves. Behavior sources: the committed differential fixture and the
10//! probe notes in docs/superpowers/plans/2026-08-07-m2-wave1-probes.md.
11
12pub(crate) mod block;
13pub(crate) mod digits;
14pub mod inline;
15pub mod lines;
16mod punctuation;
17
18use crate::doctree::Doctree;
19
20#[derive(Debug, Clone)]
21pub struct ParseOptions {
22    /// What `<document source="...">` prints (docutils `new_document` name).
23    pub source_path: String,
24    /// Sphinx mode: the Sphinx directive/role registries extend the
25    /// docutils-native ones (toctree, xref roles, ...). The binary build
26    /// path runs with this on; the docutils differential fixture off.
27    pub sphinx: bool,
28    /// The docname recorded on pending_xref nodes (sphinx `refdoc`).
29    pub docname: String,
30    /// Every docname the project discovered (sphinx `env.found_docs`).
31    /// The `toctree` directive resolves its entries against this set at
32    /// parse time, exactly as Sphinx's `TocTree.parse_content` does.
33    ///
34    /// `None` means "parsed without an environment" — a standalone parse
35    /// (the differential harnesses, `parse_rst` callers) where no document
36    /// exists, so every toctree entry resolves to nothing and `entries`/
37    /// `includefiles` stay empty. Shared by `Arc` because the build clones
38    /// these options once per source file.
39    pub found_docs: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
40    /// `exclude_patterns`, which `TocTree.parse_content` consults to tell an
41    /// *excluded* toctree target from a *nonexisting* one. Empty for a parse
42    /// without an environment, where no entry resolves anyway.
43    pub exclude_patterns: Vec<String>,
44    /// The object-signature / py-domain configuration the read phase
45    /// consumes ([`crate::py::PySigConfig`]): today the `fix_parens` roles'
46    /// `add_function_parentheses`, and from the py directives onward the
47    /// signature-wrapping and TOC-entry keys too. Defaults to sphinx's own
48    /// defaults, so a parse without a project behaves like a default one.
49    pub py: crate::py::PySigConfig,
50    /// The project source directory (sphinx `env.srcdir`), which the
51    /// `include` directive's sphinx-mode path rewrite resolves against
52    /// (`sphinx/directives/other.py:413-416` runs `env.relfn2path` on every
53    /// include argument before docutils sees it) and which the parse-time
54    /// `included`/`dependencies` records are spelled relative to.
55    ///
56    /// `None` — the default — means "parsed without a project": include
57    /// arguments then resolve the docutils way, relative to the directory
58    /// of the *containing file*, and no records are made. Standalone
59    /// parses (the differential harnesses, `parse_rst` callers) keep their
60    /// current behavior.
61    pub srcdir: Option<std::path::PathBuf>,
62    /// Sphinx's `source_encoding` config value (`config.py:244`, default
63    /// `'utf-8-sig'`), which the environment copies onto
64    /// `settings.input_encoding` (`environment/__init__.py:375`). In sphinx
65    /// mode both file-inserting directives read it as their default:
66    /// `include` through `settings.input_encoding` (`misc.py:116`) and
67    /// `literalinclude` through `config.source_encoding` (`code.py:210`);
68    /// an explicit `:encoding:` option still wins. Ignored outside sphinx
69    /// mode, where bare docutils' `'utf-8'` default applies.
70    pub source_encoding: String,
71}
72
73/// Sphinx's default `source_encoding` (`config.py:244`).
74pub const DEFAULT_SOURCE_ENCODING: &str = "utf-8-sig";
75
76impl Default for ParseOptions {
77    fn default() -> Self {
78        ParseOptions {
79            source_path: "<string>".to_string(),
80            sphinx: false,
81            docname: "index".to_string(),
82            found_docs: None,
83            exclude_patterns: Vec::new(),
84            py: crate::py::PySigConfig::default(),
85            srcdir: None,
86            source_encoding: DEFAULT_SOURCE_ENCODING.to_string(),
87        }
88    }
89}
90
91/// Pre-conversion directive tuple mirroring the M1 validation scanner's
92/// semantics (whitespace-split args, inline-admonition content routing,
93/// raw string options) — the feed for `DirectiveValidationSystem`.
94#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
95pub struct DirectiveRecord {
96    /// Source-table index of the marker line (`Doctree::sources`): a
97    /// directive inside an included file must be reported against THAT
98    /// file, since `line` is numbered within it. Deliberately not
99    /// `#[serde(default)]` (cache-shape rule, see
100    /// [`RegistryExport::program_options`]): a pre-provenance document
101    /// cache entry decoding with source 0 would pair every included
102    /// directive's line with the includer's path again.
103    pub source: u16,
104    pub name: String,
105    pub arguments: Vec<String>,
106    pub options: Vec<(String, String)>,
107    pub content: String,
108    /// 1-based marker line, within `source`.
109    pub line: u32,
110}
111
112/// A role occurrence (sphinx mode): validation + nitpicky feed.
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
114pub struct RoleRecord {
115    /// Source-table index of the enclosing text block's first line — see
116    /// [`DirectiveRecord::source`], same cache-shape rule.
117    pub source: u16,
118    /// Final role-name segment, lowercased (`:py:func:` records `func`),
119    /// with the full as-written name kept alongside.
120    pub name: String,
121    pub full_name: String,
122    pub target: String,
123    pub display: Option<String>,
124    /// 1-based line of the enclosing text block's first line, within
125    /// `source`.
126    pub line: u32,
127}
128
129/// A toctree directive occurrence (sphinx mode).
130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131pub struct ToctreeRecord {
132    pub glob: bool,
133    pub entries: Vec<ToctreeEntryRecord>,
134    /// Source-table index of the `.. toctree::` line — the section-
135    /// numbering warning (`location=toctreenode`) names this source's
136    /// path. Not `#[serde(default)]` (see [`DirectiveRecord::source`]).
137    pub source: u16,
138    /// 1-based line of the directive, within `source`.
139    pub line: u32,
140    /// Diagnostics `TocTree.parse_content` produced while resolving this
141    /// directive's entries. They ride the record (and therefore the
142    /// document cache) because the parser has no warning sink, and because
143    /// a cache hit that skipped the parse must still reproduce them.
144    pub warnings: Vec<crate::env::toctree::ToctreeWarning>,
145}
146
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
148pub struct ToctreeEntryRecord {
149    pub title: Option<String>,
150    pub target: String,
151    /// 1-based line of the entry itself.
152    pub line: u32,
153}
154
155/// One `Cmdoption.add_target_and_index` call the parse layer made
156/// (`sphinx/domains/std/__init__.py:308-315`).
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct ProgramOptionRecord {
159    /// Source-table index of the registering signature (an option inside
160    /// an included file must attribute to that file). Deliberately not
161    /// `#[serde(default)]` — the cache-shape rule
162    /// [`RegistryExport::program_options`] explains.
163    pub source: u16,
164    /// The `.. program::` in scope, `None` outside one.
165    pub program: Option<String>,
166    /// One `desc_signature['allnames']` spelling (`--file`, `-f`, ...).
167    pub name: String,
168    /// `signode['ids'][0]` — the *first* id of the signature, which is what
169    /// Sphinx registers for every spelling in it.
170    pub node_id: String,
171}
172
173/// One `PythonDomain.note_object` call the parse layer made
174/// (`PyObject.add_target_and_index`, `domains/python/_object.py:415-437`,
175/// or `PyModule.run`, `__init__.py:522`) — the fullname → `ObjectEntry`
176/// registration the env layer replays, plus the provenance the
177/// duplicate-description warning needs.
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
179pub struct PyObjectRecord {
180    /// Module-qualified full object name (`mymod.C.meth`), or the canonical
181    /// name for an `aliased` record.
182    pub fullname: String,
183    /// The desc's objtype AFTER directive-name aliasing (`py:classmethod`
184    /// registers `method`, `py:decorator` registers `function`).
185    pub objtype: String,
186    pub node_id: String,
187    /// `:canonical:` alias registrations carry `true` (`_object.py:427-437`)
188    /// — resolve-time disambiguation prefers non-aliased entries.
189    pub aliased: bool,
190    /// Source-table index of the registering signature. Deliberately not
191    /// `#[serde(default)]` (cache-shape rule, see
192    /// [`RegistryExport::program_options`]).
193    pub source: u16,
194    /// 1-based line of the signature node (`location=signode`).
195    pub lineno: u32,
196}
197
198/// One `PythonDomain.note_module` call (`PyModule.run`,
199/// `domains/python/__init__.py:515-521`) — the modname → `ModuleEntry`
200/// registration feeding the env layer and, later, the py-modindex.
201#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
202pub struct PyModuleRecord {
203    pub name: String,
204    /// `module-<name>` (or its `module-<n>` collision serial).
205    pub node_id: String,
206    /// `:synopsis:` option, `''` when absent. (A bare `:synopsis:` with no
207    /// value is Python `None` in sphinx's identity-lambda option spec; it is
208    /// recorded as `''` here — probe `module_synopsis_bare`.)
209    pub synopsis: String,
210    /// `:platform:` option, `''` when absent.
211    pub platform: String,
212    /// `:deprecated:` flag.
213    pub deprecated: bool,
214    /// Source-table index of the directive. Deliberately not
215    /// `#[serde(default)]` (cache-shape rule, see
216    /// [`RegistryExport::program_options`]).
217    pub source: u16,
218    /// 1-based line of the directive marker.
219    pub lineno: u32,
220}
221
222/// One `StandardDomain.note_object` call the parse layer made
223/// (`GenericObject`/`ConfigurationValue.add_target_and_index`).
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct ObjectRegistration {
226    /// Source-table index of the registering signature; the duplicate
227    /// warning names this source's path. Deliberately not
228    /// `#[serde(default)]` (cache-shape rule, see
229    /// [`RegistryExport::program_options`]).
230    pub source: u16,
231    /// `self.objtype` — `envvar`, `confval`, ... `describe`/`object` never
232    /// reach here: the base `add_target_and_index` is a no-op.
233    pub objtype: String,
234    /// The name `handle_signature` returned, which is what the matching
235    /// `:envvar:`/`:confval:` role resolves against.
236    pub name: String,
237    pub node_id: String,
238    /// 1-based line of the signature node (`location=signode`), for the
239    /// duplicate-description warning.
240    pub line: u32,
241}
242
243/// What the parse layer hands the environment besides the doctree itself:
244/// state that lives in the parser (the docutils id/name registry, Sphinx's
245/// `env.ref_context`) and dies with it, but that env collectors need.
246///
247/// Named for its original single job — the `document.nameids` snapshot
248/// harvested from [`crate::doctree::ids::IdRegistry`] right before it drops,
249/// which wave 4's std-domain label harvest reads. Intended to eventually
250/// ride the document cache, so it stays serde-serializable and cheap to
251/// clone.
252#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
253pub struct RegistryExport {
254    /// `(name, id, explicit)`, one entry per registered name. `id` is
255    /// `None` once a name has been duplicated away.
256    pub nameids: Vec<(String, Option<String>, bool)>,
257    /// sphinx `env.new_serialno('index')` counter value at the end of the
258    /// parse (shared by the index directive and index-entry-emitting roles).
259    pub index_serial: u32,
260    /// The std-domain registrations the object-description directives made
261    /// while running, in document order.
262    ///
263    /// Sphinx performs these from inside `add_target_and_index`, against
264    /// state the finished doctree does not carry: the program an option
265    /// belongs to comes from `env.ref_context['std:program']` and is
266    /// stamped on no node, and a `:no-typesetting:` description registers
267    /// itself and then **replaces its whole `desc` node with a bare
268    /// target** — so a doctree walk can neither recover the program nor see
269    /// that the object existed. Recording the calls keeps the env layer
270    /// exact for both.
271    ///
272    /// Deliberately *not* `#[serde(default)]`, for the reason
273    /// [`crate::document::Document::registry`] gives: a cache entry written
274    /// before this field existed must FAIL to decode so the document is
275    /// re-parsed. Defaulting it to an empty vector would let a pre-desc
276    /// cache decode cleanly, and every `:option:`/`:envvar:`/`:confval:`
277    /// in the project would then dangle against an empty registry.
278    pub program_options: Vec<ProgramOptionRecord>,
279    /// See [`Self::program_options`] — including why this is not
280    /// `#[serde(default)]` either.
281    pub std_objects: Vec<ObjectRegistration>,
282    /// The py-domain object registrations (`PythonDomain.note_object`), in
283    /// document order. Same rationale and cache-shape rule as
284    /// [`Self::program_options`]: the program state analog here is the
285    /// parser's `py:module`/`py:class` ref_context, which no doctree node
286    /// carries, and a `:no-typesetting:` py object registers and then
287    /// vanishes from the tree.
288    pub py_objects: Vec<PyObjectRecord>,
289    /// The py-domain module registrations (`PythonDomain.note_module`), in
290    /// document order. Not `#[serde(default)]` — see
291    /// [`Self::program_options`].
292    pub py_modules: Vec<PyModuleRecord>,
293    /// Diagnostics the parse raised through Sphinx's *logger* rather than
294    /// into the tree, which have nowhere else to go: docutils turns a
295    /// directive error into a `system_message` node, but a Sphinx directive
296    /// calling `logger.warning` produces no node at all. They ride the
297    /// export (and therefore the document cache) for the same reason
298    /// [`ToctreeRecord::warnings`] does — a cache hit that skipped the parse
299    /// must still reproduce them.
300    pub log_warnings: Vec<ParseLogWarning>,
301    /// Files the document pulls in at parse time (docutils
302    /// `settings.record_dependencies`, harvested by sphinx's
303    /// `DependenciesCollector`): one srcdir-relative normalized path per
304    /// successfully opened `include` target — non-doc files included,
305    /// standard includes excluded (§Scope-2b). The env layer replays these
306    /// into `env.dependencies`, which drives `get_outdated_files`. Not
307    /// `#[serde(default)]` — see [`Self::program_options`]: a pre-include
308    /// cache decoding with an empty list would never re-read the document
309    /// when an included file changes.
310    pub dependencies: Vec<String>,
311    /// The docnames this document textually includes (sphinx
312    /// `env.note_included`, recorded for every include argument that maps
313    /// to a docname — before the file is even opened, like sphinx). The
314    /// env layer replays these into `env.included`, whose only consumer is
315    /// the orphan check. Not `#[serde(default)]` — see
316    /// [`Self::program_options`].
317    pub included: Vec<String>,
318}
319
320/// One `logger.warning` a directive raised during the parse.
321///
322/// Producers: `Cmdoption.handle_signature`'s malformed option description
323/// (`domains/std/__init__.py:237-245`) and — since wave 4.5 — the three
324/// `literalinclude` reader warnings ([INC §3.4]). All are logged with no
325/// `type`/`subtype` and so render with no `[category]` suffix.
326#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
327pub struct ParseLogWarning {
328    /// Source-table index of the `location=` node's line: the replay
329    /// renders this source's path, not the document's. Deliberately not
330    /// `#[serde(default)]` (cache-shape rule, see
331    /// [`RegistryExport::program_options`]).
332    pub source: u16,
333    /// The warning text, already formatted exactly as Sphinx renders it.
334    pub message: String,
335    /// 1-based line of the `location=` node Sphinx passes.
336    pub line: u32,
337    /// When true, the rendered location appends the first source suffix to
338    /// the source path (see [`Self::rendered_path`]). Not
339    /// `#[serde(default)]` (cache-shape rule, see
340    /// [`RegistryExport::program_options`]): a pre-wave-4.5 cache entry
341    /// must MISS, not decode with the flag silently off.
342    pub doc2path_location: bool,
343}
344
345impl ParseLogWarning {
346    /// The path the rendered warning line spells for this record's source
347    /// table path.
348    ///
349    /// WHY the doubled suffix: a Sphinx `logger.warning(...,
350    /// location=(source, line))` tuple is treated by the log translator as
351    /// `(docname, lineno)` and rendered `f'{env.doc2path(docname)}:{lineno}'`
352    /// (`SP/util/logging.py:507-512`); `doc2path` on a string that is not a
353    /// known docname appends the project's first source suffix
354    /// (`SP/project.py:114-128`). The three literalinclude reader warnings
355    /// pass a full path like `<srcdir>/a.rst` as the tuple's `source`, so
356    /// Sphinx renders the doubled `<srcdir>/a.rst.rst` — byte-exact oracle
357    /// behavior (probed, [INC §3.4]), reproduced here on replay. The
358    /// appended suffix is the crate's first source suffix (`.rst`, matching
359    /// sphinx's default `source_suffix[0]` and this crate's discovery
360    /// order).
361    pub fn rendered_path(&self, source_path: &str) -> String {
362        if self.doc2path_location {
363            format!("{source_path}.rst")
364        } else {
365            source_path.to_string()
366        }
367    }
368}
369
370/// Everything a parse produces: the doctree plus the flat records the
371/// build pipeline consumes without re-walking raw source.
372pub struct ParseOutput {
373    pub doctree: Doctree,
374    pub directive_records: Vec<DirectiveRecord>,
375    pub role_records: Vec<RoleRecord>,
376    pub toctrees: Vec<ToctreeRecord>,
377    pub registry: RegistryExport,
378}
379
380/// Parse RST source into a doctree. Total: never panics, never errors —
381/// problems become `system_message` nodes, exactly like docutils.
382pub fn parse_rst(source: &str, opts: &ParseOptions) -> Doctree {
383    parse_rst_full(source, opts).doctree
384}
385
386pub fn parse_rst_full(source: &str, opts: &ParseOptions) -> ParseOutput {
387    let mut parser = block::BlockParser::new(source, &opts.source_path);
388    parser.sphinx = opts.sphinx;
389    parser.docname = opts.docname.clone();
390    parser.found_docs = opts.found_docs.clone();
391    parser.exclude_patterns = opts.exclude_patterns.clone();
392    parser.py = opts.py.clone();
393    parser.srcdir = opts.srcdir.clone();
394    parser.source_encoding = opts.source_encoding.clone();
395    parser.parse_document_full()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    /// The complete current [`RegistryExport`] shape, with one record of
403    /// every kind, so that a guard below can remove exactly ONE field and
404    /// know the decode failed for no other reason.
405    const COMPLETE_REGISTRY: &str = r#"{"nameids":[],"index_serial":0,
406        "program_options":[{"source":0,"program":null,"name":"-f","node_id":"a"}],
407        "std_objects":[{"source":0,"objtype":"envvar","name":"P","node_id":"b","line":1}],
408        "py_objects":[{"fullname":"m.f","objtype":"function","node_id":"m.f",
409            "aliased":false,"source":0,"lineno":1}],
410        "py_modules":[{"name":"m","node_id":"module-m","synopsis":"","platform":"",
411            "deprecated":false,"source":0,"lineno":1}],
412        "log_warnings":[{"source":0,"message":"m","line":2,"doc2path_location":false}],
413        "dependencies":["part.rst"],"included":["part"]}"#;
414
415    /// Decode [`COMPLETE_REGISTRY`] with the field `name` removed at
416    /// `path` (object keys and array indices), and require the failure
417    /// to be about THAT field. A blob that also omitted some other
418    /// required field would fail whether or not the field under test is
419    /// defaulting — which is how the earlier hand-written blobs stopped
420    /// discriminating (panel fix round B, [3]).
421    fn must_miss(path: &[&str], name: &str) {
422        let mut value: serde_json::Value = serde_json::from_str(COMPLETE_REGISTRY).unwrap();
423        serde_json::from_value::<RegistryExport>(value.clone())
424            .expect("the complete current shape decodes");
425        let mut slot = &mut value;
426        for part in path {
427            slot = match part.parse::<usize>() {
428                Ok(index) => &mut slot[index],
429                Err(_) => &mut slot[*part],
430            };
431        }
432        slot.as_object_mut()
433            .unwrap()
434            .remove(name)
435            .unwrap_or_else(|| panic!("{path:?}/{name} is not in the complete shape"));
436        let error = serde_json::from_value::<RegistryExport>(value)
437            .err()
438            .unwrap_or_else(|| panic!("a registry missing {path:?}/{name} decoded"))
439            .to_string();
440        assert!(
441            error.contains(&format!("missing field `{name}`")),
442            "the decode must fail on the missing {path:?}/{name}, not elsewhere: {error}"
443        );
444    }
445
446    /// [`RegistryExport`]'s newer fields carry state that cannot be recovered
447    /// from a cached doctree, so a cache entry written before they existed
448    /// must MISS rather than decode with empty vectors — decoding it would
449    /// reuse a doctree still full of unknown-directive errors and leave every
450    /// `:option:`/`:envvar:`/`:confval:` in the project dangling. Guards the
451    /// `#[serde(default)]` off these fields, which nothing else would catch:
452    /// the warm-rebuild tests round-trip the current shape only. Each case
453    /// removes exactly one top-level field from the complete shape.
454    #[test]
455    fn a_registry_written_before_the_std_records_existed_fails_to_decode() {
456        for field in [
457            // A wave-4 registry (no py record streams at all) must MISS: a
458            // defaulted empty vector would leave every py xref in the
459            // project dangling on a warm rebuild.
460            "program_options",
461            "std_objects",
462            "py_objects",
463            "py_modules",
464            "log_warnings",
465            // A wave-4.5 pre-include registry (no dependencies/included
466            // stream) must MISS: a defaulted empty list would never
467            // re-read the document when an included file changes, and
468            // would silently un-suppress the orphan warning.
469            "dependencies",
470            "included",
471        ] {
472            must_miss(&[], field);
473        }
474    }
475
476    /// The per-record `source` fields added by the provenance wave follow
477    /// the same rule: a cache entry whose records predate them must FAIL to
478    /// decode (a defaulted 0 would silently mis-attribute nothing today,
479    /// but would decode a stale record stream as current). Each case
480    /// removes exactly one field from one record of the complete shape.
481    #[test]
482    fn records_written_before_the_source_field_existed_fail_to_decode() {
483        must_miss(&["program_options", "0"], "source");
484        must_miss(&["std_objects", "0"], "source");
485        must_miss(&["py_objects", "0"], "source");
486        must_miss(&["py_modules", "0"], "source");
487        must_miss(&["log_warnings", "0"], "source");
488        // A wave-4.5 pre-literalinclude log record (no doc2path_location)
489        // must MISS: decoding it with the flag silently off would render
490        // the three literalinclude reader warnings at the un-doubled path
491        // on a warm rebuild.
492        must_miss(&["log_warnings", "0"], "doc2path_location");
493    }
494
495    /// The provenance fields panel fix round B added to the DOCUMENT-side
496    /// records (`DirectiveRecord`, `RoleRecord`, `ToctreeRecord` and the
497    /// `ToctreeWarning` it carries) follow the same rule. These ride the
498    /// document cache (`src/cache.rs`, serde_json): a pre-field entry
499    /// decoding with `source: 0` would silently report every directive,
500    /// role and toctree inside an included file against the includer's
501    /// path again — the exact regression the fields exist to close.
502    ///
503    /// Each stale blob is the CURRENT complete shape minus `source` and
504    /// nothing else, so the decode can fail for no other reason; the error
505    /// text is asserted to name that field.
506    #[test]
507    fn document_records_written_before_their_source_field_existed_fail_to_decode() {
508        fn must_miss<T: serde::de::DeserializeOwned>(complete: &str, stale: &str) {
509            serde_json::from_str::<T>(complete).expect("the current shape decodes");
510            let error = serde_json::from_str::<T>(stale)
511                .err()
512                .unwrap_or_else(|| panic!("a stale record decoded: {stale}"))
513                .to_string();
514            assert!(
515                error.contains("missing field `source`"),
516                "the decode must fail on the missing source field, not elsewhere: \
517                 {error} ({stale})"
518            );
519        }
520
521        must_miss::<DirectiveRecord>(
522            r#"{"source":1,"name":"note","arguments":[],"options":[],"content":"x","line":3}"#,
523            r#"{"name":"note","arguments":[],"options":[],"content":"x","line":3}"#,
524        );
525        must_miss::<RoleRecord>(
526            r#"{"source":1,"name":"ref","full_name":"ref","target":"t","display":null,
527                "line":3}"#,
528            r#"{"name":"ref","full_name":"ref","target":"t","display":null,"line":3}"#,
529        );
530        must_miss::<crate::env::toctree::ToctreeWarning>(
531            r#"{"source":1,"line":3,"message":"m","category":null,"kind":"MissingDocument"}"#,
532            r#"{"line":3,"message":"m","category":null,"kind":"MissingDocument"}"#,
533        );
534        must_miss::<ToctreeRecord>(
535            r#"{"glob":false,"entries":[],"source":1,"line":3,"warnings":[]}"#,
536            r#"{"glob":false,"entries":[],"line":3,"warnings":[]}"#,
537        );
538    }
539}