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}