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    /// The optional caption is a title in Sphinx's search index; entry
135    /// labels and targets are not searchable body text.
136    pub caption: Option<String>,
137    /// Source-table index of the `.. toctree::` line — the section-
138    /// numbering warning (`location=toctreenode`) names this source's
139    /// path. Not `#[serde(default)]` (see [`DirectiveRecord::source`]).
140    pub source: u16,
141    /// 1-based line of the directive, within `source`.
142    pub line: u32,
143    /// Diagnostics `TocTree.parse_content` produced while resolving this
144    /// directive's entries. They ride the record (and therefore the
145    /// document cache) because the parser has no warning sink, and because
146    /// a cache hit that skipped the parse must still reproduce them.
147    pub warnings: Vec<crate::env::toctree::ToctreeWarning>,
148}
149
150#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
151pub struct ToctreeEntryRecord {
152    pub title: Option<String>,
153    pub target: String,
154    /// 1-based line of the entry itself.
155    pub line: u32,
156}
157
158/// One `Cmdoption.add_target_and_index` call the parse layer made
159/// (`sphinx/domains/std/__init__.py:308-315`).
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct ProgramOptionRecord {
162    /// Source-table index of the registering signature (an option inside
163    /// an included file must attribute to that file). Deliberately not
164    /// `#[serde(default)]` — the cache-shape rule
165    /// [`RegistryExport::program_options`] explains.
166    pub source: u16,
167    /// The `.. program::` in scope, `None` outside one.
168    pub program: Option<String>,
169    /// One `desc_signature['allnames']` spelling (`--file`, `-f`, ...).
170    pub name: String,
171    /// `signode['ids'][0]` — the *first* id of the signature, which is what
172    /// Sphinx registers for every spelling in it.
173    pub node_id: String,
174}
175
176/// One `PythonDomain.note_object` call the parse layer made
177/// (`PyObject.add_target_and_index`, `domains/python/_object.py:415-437`,
178/// or `PyModule.run`, `__init__.py:522`) — the fullname → `ObjectEntry`
179/// registration the env layer replays, plus the provenance the
180/// duplicate-description warning needs.
181#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
182pub struct PyObjectRecord {
183    /// Module-qualified full object name (`mymod.C.meth`), or the canonical
184    /// name for an `aliased` record.
185    pub fullname: String,
186    /// The desc's objtype AFTER directive-name aliasing (`py:classmethod`
187    /// registers `method`, `py:decorator` registers `function`).
188    pub objtype: String,
189    pub node_id: String,
190    /// `:canonical:` alias registrations carry `true` (`_object.py:427-437`)
191    /// — resolve-time disambiguation prefers non-aliased entries.
192    pub aliased: bool,
193    /// Source-table index of the registering signature. Deliberately not
194    /// `#[serde(default)]` (cache-shape rule, see
195    /// [`RegistryExport::program_options`]).
196    pub source: u16,
197    /// 1-based line of the signature node (`location=signode`).
198    pub lineno: u32,
199}
200
201/// One `PythonDomain.note_module` call (`PyModule.run`,
202/// `domains/python/__init__.py:515-521`) — the modname → `ModuleEntry`
203/// registration feeding the env layer and, later, the py-modindex.
204#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
205pub struct PyModuleRecord {
206    pub name: String,
207    /// `module-<name>` (or its `module-<n>` collision serial).
208    pub node_id: String,
209    /// `:synopsis:` option, `''` when absent. (A bare `:synopsis:` with no
210    /// value is Python `None` in sphinx's identity-lambda option spec; it is
211    /// recorded as `''` here — probe `module_synopsis_bare`.)
212    pub synopsis: String,
213    /// `:platform:` option, `''` when absent.
214    pub platform: String,
215    /// `:deprecated:` flag.
216    pub deprecated: bool,
217    /// Source-table index of the directive. Deliberately not
218    /// `#[serde(default)]` (cache-shape rule, see
219    /// [`RegistryExport::program_options`]).
220    pub source: u16,
221    /// 1-based line of the directive marker.
222    pub lineno: u32,
223}
224
225/// One `StandardDomain.note_object` call the parse layer made
226/// (`GenericObject`/`ConfigurationValue.add_target_and_index`).
227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228pub struct ObjectRegistration {
229    /// Source-table index of the registering signature; the duplicate
230    /// warning names this source's path. Deliberately not
231    /// `#[serde(default)]` (cache-shape rule, see
232    /// [`RegistryExport::program_options`]).
233    pub source: u16,
234    /// `self.objtype` — `envvar`, `confval`, ... `describe`/`object` never
235    /// reach here: the base `add_target_and_index` is a no-op.
236    pub objtype: String,
237    /// The name `handle_signature` returned, which is what the matching
238    /// `:envvar:`/`:confval:` role resolves against.
239    pub name: String,
240    pub node_id: String,
241    /// 1-based line of the signature node (`location=signode`), for the
242    /// duplicate-description warning.
243    pub line: u32,
244}
245
246/// What the parse layer hands the environment besides the doctree itself:
247/// state that lives in the parser (the docutils id/name registry, Sphinx's
248/// `env.ref_context`) and dies with it, but that env collectors need.
249///
250/// Named for its original single job — the `document.nameids` snapshot
251/// harvested from [`crate::doctree::ids::IdRegistry`] right before it drops,
252/// which wave 4's std-domain label harvest reads. Intended to eventually
253/// ride the document cache, so it stays serde-serializable and cheap to
254/// clone.
255#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
256pub struct RegistryExport {
257    /// `(name, id, explicit)`, one entry per registered name. `id` is
258    /// `None` once a name has been duplicated away.
259    pub nameids: Vec<(String, Option<String>, bool)>,
260    /// sphinx `env.new_serialno('index')` counter value at the end of the
261    /// parse (shared by the index directive and index-entry-emitting roles).
262    pub index_serial: u32,
263    /// The std-domain registrations the object-description directives made
264    /// while running, in document order.
265    ///
266    /// Sphinx performs these from inside `add_target_and_index`, against
267    /// state the finished doctree does not carry: the program an option
268    /// belongs to comes from `env.ref_context['std:program']` and is
269    /// stamped on no node, and a `:no-typesetting:` description registers
270    /// itself and then **replaces its whole `desc` node with a bare
271    /// target** — so a doctree walk can neither recover the program nor see
272    /// that the object existed. Recording the calls keeps the env layer
273    /// exact for both.
274    ///
275    /// Deliberately *not* `#[serde(default)]`, for the reason
276    /// [`crate::document::Document::registry`] gives: a cache entry written
277    /// before this field existed must FAIL to decode so the document is
278    /// re-parsed. Defaulting it to an empty vector would let a pre-desc
279    /// cache decode cleanly, and every `:option:`/`:envvar:`/`:confval:`
280    /// in the project would then dangle against an empty registry.
281    pub program_options: Vec<ProgramOptionRecord>,
282    /// See [`Self::program_options`] — including why this is not
283    /// `#[serde(default)]` either.
284    pub std_objects: Vec<ObjectRegistration>,
285    /// The py-domain object registrations (`PythonDomain.note_object`), in
286    /// document order. Same rationale and cache-shape rule as
287    /// [`Self::program_options`]: the program state analog here is the
288    /// parser's `py:module`/`py:class` ref_context, which no doctree node
289    /// carries, and a `:no-typesetting:` py object registers and then
290    /// vanishes from the tree.
291    pub py_objects: Vec<PyObjectRecord>,
292    /// The py-domain module registrations (`PythonDomain.note_module`), in
293    /// document order. Not `#[serde(default)]` — see
294    /// [`Self::program_options`].
295    pub py_modules: Vec<PyModuleRecord>,
296    /// Diagnostics the parse raised through Sphinx's *logger* rather than
297    /// into the tree, which have nowhere else to go: docutils turns a
298    /// directive error into a `system_message` node, but a Sphinx directive
299    /// calling `logger.warning` produces no node at all. They ride the
300    /// export (and therefore the document cache) for the same reason
301    /// [`ToctreeRecord::warnings`] does — a cache hit that skipped the parse
302    /// must still reproduce them.
303    pub log_warnings: Vec<ParseLogWarning>,
304    /// Files the document pulls in at parse time (docutils
305    /// `settings.record_dependencies`, harvested by sphinx's
306    /// `DependenciesCollector`): one srcdir-relative normalized path per
307    /// successfully opened `include` target — non-doc files included,
308    /// standard includes excluded (§Scope-2b). The env layer replays these
309    /// into `env.dependencies`, which drives `get_outdated_files`. Not
310    /// `#[serde(default)]` — see [`Self::program_options`]: a pre-include
311    /// cache decoding with an empty list would never re-read the document
312    /// when an included file changes.
313    pub dependencies: Vec<String>,
314    /// The docnames this document textually includes (sphinx
315    /// `env.note_included`, recorded for every include argument that maps
316    /// to a docname — before the file is even opened, like sphinx). The
317    /// env layer replays these into `env.included`, whose only consumer is
318    /// the orphan check. Not `#[serde(default)]` — see
319    /// [`Self::program_options`].
320    pub included: Vec<String>,
321}
322
323/// One `logger.warning` a directive raised during the parse.
324///
325/// Producers: `Cmdoption.handle_signature`'s malformed option description
326/// (`domains/std/__init__.py:237-245`) and — since wave 4.5 — the three
327/// `literalinclude` reader warnings ([INC §3.4]). All are logged with no
328/// `type`/`subtype` and so render with no `[category]` suffix.
329#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
330pub struct ParseLogWarning {
331    /// Source-table index of the `location=` node's line: the replay
332    /// renders this source's path, not the document's. Deliberately not
333    /// `#[serde(default)]` (cache-shape rule, see
334    /// [`RegistryExport::program_options`]).
335    pub source: u16,
336    /// The warning text, already formatted exactly as Sphinx renders it.
337    pub message: String,
338    /// 1-based line of the `location=` node Sphinx passes.
339    pub line: u32,
340    /// When true, the rendered location appends the first source suffix to
341    /// the source path (see [`Self::rendered_path`]). Not
342    /// `#[serde(default)]` (cache-shape rule, see
343    /// [`RegistryExport::program_options`]): a pre-wave-4.5 cache entry
344    /// must MISS, not decode with the flag silently off.
345    pub doc2path_location: bool,
346}
347
348impl ParseLogWarning {
349    /// The path the rendered warning line spells for this record's source
350    /// table path.
351    ///
352    /// WHY the doubled suffix: a Sphinx `logger.warning(...,
353    /// location=(source, line))` tuple is treated by the log translator as
354    /// `(docname, lineno)` and rendered `f'{env.doc2path(docname)}:{lineno}'`
355    /// (`SP/util/logging.py:507-512`); `doc2path` on a string that is not a
356    /// known docname appends the project's first source suffix
357    /// (`SP/project.py:114-128`). The three literalinclude reader warnings
358    /// pass a full path like `<srcdir>/a.rst` as the tuple's `source`, so
359    /// Sphinx renders the doubled `<srcdir>/a.rst.rst` — byte-exact oracle
360    /// behavior (probed, [INC §3.4]), reproduced here on replay. The
361    /// appended suffix is the crate's first source suffix (`.rst`, matching
362    /// sphinx's default `source_suffix[0]` and this crate's discovery
363    /// order).
364    pub fn rendered_path(&self, source_path: &str) -> String {
365        if self.doc2path_location {
366            format!("{source_path}.rst")
367        } else {
368            source_path.to_string()
369        }
370    }
371}
372
373/// Everything a parse produces: the doctree plus the flat records the
374/// build pipeline consumes without re-walking raw source.
375pub struct ParseOutput {
376    pub doctree: Doctree,
377    pub directive_records: Vec<DirectiveRecord>,
378    pub role_records: Vec<RoleRecord>,
379    pub toctrees: Vec<ToctreeRecord>,
380    pub registry: RegistryExport,
381}
382
383/// Parse RST source into a doctree. Total: never panics, never errors —
384/// problems become `system_message` nodes, exactly like docutils.
385pub fn parse_rst(source: &str, opts: &ParseOptions) -> Doctree {
386    parse_rst_full(source, opts).doctree
387}
388
389pub fn parse_rst_full(source: &str, opts: &ParseOptions) -> ParseOutput {
390    let mut parser = block::BlockParser::new(source, &opts.source_path);
391    parser.sphinx = opts.sphinx;
392    parser.docname = opts.docname.clone();
393    parser.found_docs = opts.found_docs.clone();
394    parser.exclude_patterns = opts.exclude_patterns.clone();
395    parser.py = opts.py.clone();
396    parser.srcdir = opts.srcdir.clone();
397    parser.source_encoding = opts.source_encoding.clone();
398    parser.parse_document_full()
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    /// The complete current [`RegistryExport`] shape, with one record of
406    /// every kind, so that a guard below can remove exactly ONE field and
407    /// know the decode failed for no other reason.
408    const COMPLETE_REGISTRY: &str = r#"{"nameids":[],"index_serial":0,
409        "program_options":[{"source":0,"program":null,"name":"-f","node_id":"a"}],
410        "std_objects":[{"source":0,"objtype":"envvar","name":"P","node_id":"b","line":1}],
411        "py_objects":[{"fullname":"m.f","objtype":"function","node_id":"m.f",
412            "aliased":false,"source":0,"lineno":1}],
413        "py_modules":[{"name":"m","node_id":"module-m","synopsis":"","platform":"",
414            "deprecated":false,"source":0,"lineno":1}],
415        "log_warnings":[{"source":0,"message":"m","line":2,"doc2path_location":false}],
416        "dependencies":["part.rst"],"included":["part"]}"#;
417
418    /// Decode [`COMPLETE_REGISTRY`] with the field `name` removed at
419    /// `path` (object keys and array indices), and require the failure
420    /// to be about THAT field. A blob that also omitted some other
421    /// required field would fail whether or not the field under test is
422    /// defaulting — which is how the earlier hand-written blobs stopped
423    /// discriminating (panel fix round B, [3]).
424    fn must_miss(path: &[&str], name: &str) {
425        let mut value: serde_json::Value = serde_json::from_str(COMPLETE_REGISTRY).unwrap();
426        serde_json::from_value::<RegistryExport>(value.clone())
427            .expect("the complete current shape decodes");
428        let mut slot = &mut value;
429        for part in path {
430            slot = match part.parse::<usize>() {
431                Ok(index) => &mut slot[index],
432                Err(_) => &mut slot[*part],
433            };
434        }
435        slot.as_object_mut()
436            .unwrap()
437            .remove(name)
438            .unwrap_or_else(|| panic!("{path:?}/{name} is not in the complete shape"));
439        let error = serde_json::from_value::<RegistryExport>(value)
440            .err()
441            .unwrap_or_else(|| panic!("a registry missing {path:?}/{name} decoded"))
442            .to_string();
443        assert!(
444            error.contains(&format!("missing field `{name}`")),
445            "the decode must fail on the missing {path:?}/{name}, not elsewhere: {error}"
446        );
447    }
448
449    /// [`RegistryExport`]'s newer fields carry state that cannot be recovered
450    /// from a cached doctree, so a cache entry written before they existed
451    /// must MISS rather than decode with empty vectors — decoding it would
452    /// reuse a doctree still full of unknown-directive errors and leave every
453    /// `:option:`/`:envvar:`/`:confval:` in the project dangling. Guards the
454    /// `#[serde(default)]` off these fields, which nothing else would catch:
455    /// the warm-rebuild tests round-trip the current shape only. Each case
456    /// removes exactly one top-level field from the complete shape.
457    #[test]
458    fn a_registry_written_before_the_std_records_existed_fails_to_decode() {
459        for field in [
460            // A wave-4 registry (no py record streams at all) must MISS: a
461            // defaulted empty vector would leave every py xref in the
462            // project dangling on a warm rebuild.
463            "program_options",
464            "std_objects",
465            "py_objects",
466            "py_modules",
467            "log_warnings",
468            // A wave-4.5 pre-include registry (no dependencies/included
469            // stream) must MISS: a defaulted empty list would never
470            // re-read the document when an included file changes, and
471            // would silently un-suppress the orphan warning.
472            "dependencies",
473            "included",
474        ] {
475            must_miss(&[], field);
476        }
477    }
478
479    /// The per-record `source` fields added by the provenance wave follow
480    /// the same rule: a cache entry whose records predate them must FAIL to
481    /// decode (a defaulted 0 would silently mis-attribute nothing today,
482    /// but would decode a stale record stream as current). Each case
483    /// removes exactly one field from one record of the complete shape.
484    #[test]
485    fn records_written_before_the_source_field_existed_fail_to_decode() {
486        must_miss(&["program_options", "0"], "source");
487        must_miss(&["std_objects", "0"], "source");
488        must_miss(&["py_objects", "0"], "source");
489        must_miss(&["py_modules", "0"], "source");
490        must_miss(&["log_warnings", "0"], "source");
491        // A wave-4.5 pre-literalinclude log record (no doc2path_location)
492        // must MISS: decoding it with the flag silently off would render
493        // the three literalinclude reader warnings at the un-doubled path
494        // on a warm rebuild.
495        must_miss(&["log_warnings", "0"], "doc2path_location");
496    }
497
498    /// The provenance fields panel fix round B added to the DOCUMENT-side
499    /// records (`DirectiveRecord`, `RoleRecord`, `ToctreeRecord` and the
500    /// `ToctreeWarning` it carries) follow the same rule. These ride the
501    /// document cache (`src/cache.rs`, serde_json): a pre-field entry
502    /// decoding with `source: 0` would silently report every directive,
503    /// role and toctree inside an included file against the includer's
504    /// path again — the exact regression the fields exist to close.
505    ///
506    /// Each stale blob is the CURRENT complete shape minus `source` and
507    /// nothing else, so the decode can fail for no other reason; the error
508    /// text is asserted to name that field.
509    #[test]
510    fn document_records_written_before_their_source_field_existed_fail_to_decode() {
511        fn must_miss<T: serde::de::DeserializeOwned>(complete: &str, stale: &str) {
512            serde_json::from_str::<T>(complete).expect("the current shape decodes");
513            let error = serde_json::from_str::<T>(stale)
514                .err()
515                .unwrap_or_else(|| panic!("a stale record decoded: {stale}"))
516                .to_string();
517            assert!(
518                error.contains("missing field `source`"),
519                "the decode must fail on the missing source field, not elsewhere: \
520                 {error} ({stale})"
521            );
522        }
523
524        must_miss::<DirectiveRecord>(
525            r#"{"source":1,"name":"note","arguments":[],"options":[],"content":"x","line":3}"#,
526            r#"{"name":"note","arguments":[],"options":[],"content":"x","line":3}"#,
527        );
528        must_miss::<RoleRecord>(
529            r#"{"source":1,"name":"ref","full_name":"ref","target":"t","display":null,
530                "line":3}"#,
531            r#"{"name":"ref","full_name":"ref","target":"t","display":null,"line":3}"#,
532        );
533        must_miss::<crate::env::toctree::ToctreeWarning>(
534            r#"{"source":1,"line":3,"message":"m","category":null,"kind":"MissingDocument"}"#,
535            r#"{"line":3,"message":"m","category":null,"kind":"MissingDocument"}"#,
536        );
537        must_miss::<ToctreeRecord>(
538            r#"{"glob":false,"entries":[],"source":1,"line":3,"warnings":[]}"#,
539            r#"{"glob":false,"entries":[],"line":3,"warnings":[]}"#,
540        );
541    }
542}