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}