Skip to main content

aozora/
json.rs

1//! Driver-shared wire format for serialising `aozora` parser output.
2//!
3//! Three driver crates (`aozora-ffi`, `aozora-wasm`, `aozora-py`) all
4//! need to project the owned-AST parser output to a stable byte stream.
5//! This module is the **single authority** for that projection — each
6//! driver calls into here and is guaranteed bit-identical output
7//! across language boundaries.
8//!
9//! # Schema envelope
10//!
11//! Every JSON envelope carries [`SCHEMA_VERSION`] and a `data` array.
12//! [`SCHEMA_VERSION`] is bumped on any breaking change to the
13//! serialised shape (variant additions, field renames, envelope
14//! changes). Clients that read the wire format SHOULD branch on the
15//! version to decide their handling — one schema makes no guarantees of
16//! forward-compatibility with later schemas.
17//!
18//! # Stability vs. `non_exhaustive`
19//!
20//! The wire format projects stable [`crate::Diagnostic`] and
21//! [`crate::NodeView`] values. Breaking shape changes require a
22//! [`SCHEMA_VERSION`] bump.
23
24use serde::Serialize;
25
26use crate::encoding::gaiji;
27use crate::spec::SLUGS;
28use crate::{DiagnosticSource, Severity, Snapshot};
29
30/// Wire-format schema version. Bumped on any breaking change to the
31/// serialised shape (variant additions, field renames, envelope
32/// changes).
33///
34/// Schema 3 uses original-source spans for every endpoint, including
35/// paired containers.
36pub const SCHEMA_VERSION: u32 = 3;
37
38/// Project a slice of [`crate::Diagnostic`] into a `{ schemaVersion, data }`
39/// JSON envelope. Every entry has the shape
40/// `{ kind, span: { start, end }, codepoint? }`.
41///
42/// Empty input has an empty `data` array.
43#[must_use]
44pub fn diagnostics(diagnostics: &[crate::Diagnostic]) -> String {
45    serialize_envelope(&diagnostic_entries(diagnostics))
46}
47
48/// The structured `Diagnostic` records that back `diagnostics()` —
49/// prefer this to re-parsing the JSON when a caller needs the values
50/// directly (e.g. a Wasm binding building JS objects).
51#[must_use]
52pub fn diagnostic_entries(diagnostics: &[crate::Diagnostic]) -> Vec<Diagnostic> {
53    diagnostics.iter().map(Diagnostic::from).collect()
54}
55
56/// Project an [`Snapshot`]'s source-keyed node side-table into a
57/// `{ schemaVersion, data }` JSON envelope.
58///
59/// Every entry has the shape `{ kind, span: { start, end } }`,
60/// source-coordinate, sorted by `span.start`.
61#[must_use]
62pub fn nodes(snapshot: &Snapshot) -> String {
63    serialize_envelope(&node_entries(snapshot))
64}
65
66/// The structured `Node` records that back `nodes()` — prefer this to
67/// re-parsing the JSON when a caller needs the values directly.
68#[must_use]
69pub fn node_entries(snapshot: &Snapshot) -> Vec<Node> {
70    snapshot
71        .nodes()
72        .iter()
73        .map(|node| Node {
74            kind: node.kind().as_json_tag(),
75            span: node.span().into(),
76        })
77        .collect()
78}
79
80/// Project an [`Snapshot`]'s pair table into a
81/// `{ schemaVersion, data }` JSON envelope. Every entry has the shape
82/// `{ kind, open: { start, end }, close: { start, end } }`.
83///
84/// One entry per matched open/close pair; unmatched closes and
85/// unclosed opens are excluded (they have no partner span and would
86/// only confuse editor surfaces). Useful for LSP requests like
87/// `textDocument/linkedEditingRange` and
88/// `textDocument/documentHighlight`.
89///
90/// An empty parse has an empty `data` array.
91#[must_use]
92pub fn pairs(snapshot: &Snapshot) -> String {
93    serialize_envelope(&pair_entries(snapshot))
94}
95
96/// The structured `Pair` records that back `pairs()` — prefer this to
97/// re-parsing the JSON when a caller needs the values directly.
98#[must_use]
99pub fn pair_entries(snapshot: &Snapshot) -> Vec<Pair> {
100    snapshot
101        .pairs()
102        .iter()
103        .map(|link| Pair {
104            kind: link.kind.as_json_tag(),
105            open: link.open.into(),
106            close: link.close.into(),
107        })
108        .collect()
109}
110
111/// Project an [`Snapshot`]'s container open/close pair table into a
112/// `{ schemaVersion, data }` JSON envelope.
113///
114/// Each entry has the shape
115/// `{ kind, open: { start, end }, close: { start, end } }` in source
116/// byte coordinates.
117///
118/// An empty parse has an empty `data` array.
119#[must_use]
120pub fn container_pairs(snapshot: &Snapshot) -> String {
121    serialize_envelope(&container_pair_entries(snapshot))
122}
123
124/// The structured `ContainerPair` records that back
125/// `container_pairs()` — prefer this to re-parsing the JSON when a caller
126/// needs the values directly.
127#[must_use]
128pub fn container_pair_entries(snapshot: &Snapshot) -> Vec<ContainerPair> {
129    snapshot
130        .container_pairs()
131        .iter()
132        .map(|pair| ContainerPair {
133            kind: pair.kind().as_str(),
134            open: pair.open().into(),
135            close: pair.close().into(),
136        })
137        .collect()
138}
139
140/// Project the canonical [`crate::Catalogue`] into a
141/// `{ schemaVersion, data }` JSON envelope.
142///
143/// Each entry has the shape `{ canonical, family, accepts_param, doc,
144/// partner }`: `family` is the camelCase form of the
145/// [`crate::CatalogueFamily`] variant, `partner` is `null` for non-paired
146/// families. A static catalogue, independent of any parse — it powers
147/// editor completion menus for `[#…]` annotations without
148/// re-implementing the table per driver (`aozora-wasm` / `aozora-py`
149/// both call this).
150#[must_use]
151pub fn slugs() -> String {
152    serialize_envelope(&slug_entries())
153}
154
155/// The structured `Slug` records that back `slugs()` — prefer this to
156/// re-parsing the JSON when a caller needs the catalogue directly.
157#[must_use]
158pub fn slug_entries() -> Vec<Slug> {
159    SLUGS
160        .iter()
161        .map(|s| Slug {
162            canonical: s.canonical,
163            family: s.family.as_json_tag(),
164            accepts_param: s.accepts_param,
165            doc: s.doc,
166            partner: s.partner,
167        })
168        .collect()
169}
170
171/// Project resolved `※[#…]` gaiji references from a [`Snapshot`] into a
172/// `{ schemaVersion, data }` JSON envelope.
173///
174/// Each entry is
175/// `{ span: { start, end }, description, mencode, codepoint, resolved }`
176/// in source-byte coordinates; `mencode` / `codepoint` / `resolved` are
177/// `null` when absent or unresolved.
178///
179/// Powers inlay-hint UIs (`→GLYPH` after each reference) and batch gaiji
180/// audits. The core scan and resolver are authoritative; this is only their
181/// wire projection.
182///
183/// Empty and gaiji-free sources have an empty `data` array.
184#[must_use]
185pub fn gaiji(snapshot: &Snapshot) -> String {
186    serialize_envelope(&gaiji_entries(snapshot))
187}
188
189/// The structured `GaijiResolution` records that back `gaiji()` —
190/// prefer this to re-parsing the JSON when a caller needs the values
191/// directly.
192#[must_use]
193pub fn gaiji_entries(snapshot: &Snapshot) -> Vec<GaijiResolution> {
194    snapshot
195        .gaiji_resolutions()
196        .iter()
197        .cloned()
198        .map(Into::into)
199        .collect()
200}
201
202/// Resolve one gaiji at a source byte offset as a typed wire record.
203#[must_use]
204pub fn gaiji_entry_at(snapshot: &Snapshot, byte_offset: usize) -> Option<GaijiResolution> {
205    snapshot
206        .gaiji_resolution_at(byte_offset)
207        .cloned()
208        .map(Into::into)
209}
210
211// ────────────────────────────────────────────────────────────────────
212// Internal: envelope + wire structs
213// ────────────────────────────────────────────────────────────────────
214
215#[derive(Serialize)]
216#[serde(rename_all = "camelCase")]
217struct Envelope<'a, T> {
218    schema_version: u32,
219    data: &'a [T],
220}
221
222// ────────────────────────────────────────────────────────────────────
223// JSON Schema introspection
224// ────────────────────────────────────────────────────────────────────
225
226/// JSON Schema (draft 2020-12) describing the
227/// [`diagnostics`] envelope output.
228///
229/// Schema-feature only. Used by `xtask schema dump` to commit the
230/// schema artefact under `crates/aozora-conformance/json/`, and by the
231/// `aozora spec schema` CLI subcommand for ad-hoc introspection.
232#[cfg(feature = "schema")]
233#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
234#[must_use]
235pub fn schema_diagnostics() -> serde_json::Value {
236    envelope_schema(
237        "AozoraDiagnosticsEnvelope",
238        "Envelope returned by aozora::json::diagnostics.",
239        schemars::schema_for!(Diagnostic),
240    )
241}
242
243/// JSON Schema for the [`nodes`] envelope output.
244#[cfg(feature = "schema")]
245#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
246#[must_use]
247pub fn schema_nodes() -> serde_json::Value {
248    envelope_schema(
249        "AozoraNodesEnvelope",
250        "Envelope returned by aozora::json::nodes.",
251        schemars::schema_for!(Node),
252    )
253}
254
255/// JSON Schema for the [`pairs`] envelope output.
256#[cfg(feature = "schema")]
257#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
258#[must_use]
259pub fn schema_pairs() -> serde_json::Value {
260    envelope_schema(
261        "AozoraPairsEnvelope",
262        "Envelope returned by aozora::json::pairs.",
263        schemars::schema_for!(Pair),
264    )
265}
266
267/// JSON Schema for the [`container_pairs`] envelope output.
268#[cfg(feature = "schema")]
269#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
270#[must_use]
271pub fn schema_container_pairs() -> serde_json::Value {
272    envelope_schema(
273        "AozoraContainerPairsEnvelope",
274        "Envelope returned by aozora::json::container_pairs.",
275        schemars::schema_for!(ContainerPair),
276    )
277}
278
279/// JSON Schema for the [`gaiji`] envelope output.
280#[cfg(feature = "schema")]
281#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
282#[must_use]
283pub fn schema_gaiji() -> serde_json::Value {
284    envelope_schema(
285        "AozoraGaijiEnvelope",
286        "Envelope returned by aozora::json::gaiji.",
287        schemars::schema_for!(GaijiResolution),
288    )
289}
290
291/// JSON Schema for the [`slugs`] envelope output.
292#[cfg(feature = "schema")]
293#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
294#[must_use]
295pub fn schema_slugs() -> serde_json::Value {
296    envelope_schema(
297        "AozoraSlugsEnvelope",
298        "Envelope returned by aozora::json::slugs.",
299        schemars::schema_for!(Slug),
300    )
301}
302
303/// Wrap the per-entry schema in the canonical
304/// `{schemaVersion, data: […]}` envelope. The envelope shape is
305/// shared by wire functions; only the inner item schema varies.
306#[cfg(feature = "schema")]
307#[expect(
308    clippy::expect_used,
309    reason = "the envelope root is constructed from an object literal"
310)]
311fn envelope_schema(
312    title: &str,
313    description: &str,
314    item_schema: schemars::Schema,
315) -> serde_json::Value {
316    // `schema_for!(ItemWire)` returns a self-contained document: its
317    // shared sub-types (e.g. `Span`) live under a root `$defs` and
318    // are referenced as `#/$defs/…`. Embedding it verbatim as `items`
319    // would bury that `$defs` under `properties/data/items`, leaving the
320    // `#/$defs/…` refs — which resolve against the *document* root —
321    // dangling, so strict resolvers (quicktype, and any other consumer
322    // of the published schema) reject it. Hoist the item schema's
323    // `$defs` to the envelope root and drop its redundant per-item
324    // `$schema` dialect marker so the refs resolve against the root.
325    let mut item = item_schema.to_value();
326    let defs = item.as_object_mut().and_then(|obj| {
327        obj.remove("$schema");
328        obj.remove("$defs")
329    });
330    let mut root = serde_json::json!({
331        "$schema": "https://json-schema.org/draft/2020-12/schema",
332        "title": title,
333        "description": description,
334        "type": "object",
335        "additionalProperties": false,
336        "required": ["schemaVersion", "data"],
337        "properties": {
338            "schemaVersion": {
339                "description": "Wire schema version. See aozora::json::SCHEMA_VERSION.",
340                "type": "integer",
341                "const": SCHEMA_VERSION,
342            },
343            "data": {
344                "description": "Per-entry payload array; one item per emitted diagnostic / node / pair.",
345                "type": "array",
346                "items": item,
347            },
348        },
349    });
350    if let Some(defs) = defs {
351        root.as_object_mut()
352            .expect("envelope root is a JSON object literal")
353            .insert("$defs".to_owned(), defs);
354    }
355    root
356}
357
358fn serialize_envelope<T: Serialize>(data: &[T]) -> String {
359    let env = Envelope {
360        schema_version: SCHEMA_VERSION,
361        data,
362    };
363    match serde_json::to_string(&env) {
364        Ok(json) => json,
365        Err(error) => unreachable!("wire envelope serialization failed: {error}"),
366    }
367}
368
369/// One half-open `[start, end)` byte span in a wire envelope.
370#[derive(Debug, Clone, Copy, Serialize)]
371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
372pub struct Span {
373    start: u32,
374    end: u32,
375}
376
377impl From<crate::Span> for Span {
378    fn from(s: crate::Span) -> Self {
379        Self {
380            start: s.start,
381            end: s.end,
382        }
383    }
384}
385
386/// One `diagnostics` envelope entry — a projected [`crate::Diagnostic`].
387#[derive(Debug, Clone, Copy, Serialize)]
388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
389pub struct Diagnostic {
390    kind: &'static str,
391    severity: &'static str,
392    source: &'static str,
393    span: Span,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    codepoint: Option<u32>,
396}
397
398impl From<&crate::Diagnostic> for Diagnostic {
399    fn from(d: &crate::Diagnostic) -> Self {
400        // Pull the codepoint payload off the variants that carry one.
401        // The accessors collapse the Internal/Source distinction for
402        // severity/source/code; the codepoint is the only payload that
403        // survives variant-by-variant.
404        let codepoint = match d {
405            crate::Diagnostic::SourceContainsPua { codepoint, .. } => Some(u32::from(*codepoint)),
406            _ => None,
407        };
408        // Strip the `aozora::lex::` / `aozora::internal` prefix so the
409        // wire `kind` stays terse — this matches the prior wire layout
410        // where the tag was the trailing token (e.g. "source_contains_pua",
411        // "unclosed_bracket"). Internal codes get the same trailing-token
412        // treatment so the wire `kind` is uniform across the user-facing
413        // and internal axes; consumers that need the full namespaced ID
414        // can still rely on `Diagnostic::code()`.
415        let kind = d.code().rsplit("::").next().unwrap_or("unknown");
416        Self {
417            kind,
418            severity: severity_str(d.severity()),
419            source: source_str(d.source()),
420            span: d.span().into(),
421            codepoint,
422        }
423    }
424}
425
426const fn severity_str(s: Severity) -> &'static str {
427    match s {
428        Severity::Warning => "warning",
429        Severity::Note => "note",
430        Severity::Error => "error",
431    }
432}
433
434const fn source_str(s: DiagnosticSource) -> &'static str {
435    match s {
436        DiagnosticSource::Source => "source",
437        DiagnosticSource::Internal => "internal",
438    }
439}
440
441/// One `nodes` envelope entry — a classified node span in source coords.
442#[derive(Debug, Clone, Copy, Serialize)]
443#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
444pub struct Node {
445    kind: &'static str,
446    span: Span,
447}
448
449/// One `pairs` envelope entry — a matched bracket pair.
450#[derive(Debug, Clone, Copy, Serialize)]
451#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
452pub struct Pair {
453    kind: &'static str,
454    open: Span,
455    close: Span,
456}
457
458/// One `container_pairs` envelope entry.
459#[derive(Debug, Clone, Copy, Serialize)]
460#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
461pub struct ContainerPair {
462    kind: &'static str,
463    open: Span,
464    close: Span,
465}
466
467/// One `slugs` envelope entry — a row of the annotation slug catalogue.
468#[derive(Debug, Clone, Copy, Serialize)]
469#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
470pub struct Slug {
471    canonical: &'static str,
472    family: &'static str,
473    accepts_param: bool,
474    doc: &'static str,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    partner: Option<&'static str>,
477}
478
479/// One `gaiji` envelope entry — a resolved `※[#…]` reference.
480#[derive(Debug, Clone, Serialize)]
481#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
482pub struct GaijiResolution {
483    span: Span,
484    description: String,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    mencode: Option<String>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    codepoint: Option<u32>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    resolved: Option<String>,
491}
492
493impl From<gaiji::GaijiResolution> for GaijiResolution {
494    fn from(g: gaiji::GaijiResolution) -> Self {
495        Self {
496            span: g.span().into(),
497            description: g.description().to_owned(),
498            mencode: g.mencode().map(str::to_owned),
499            codepoint: g.codepoint(),
500            resolved: g.resolved().map(str::to_owned),
501        }
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::Document;
509    use serde::ser::Error as _;
510
511    struct RejectSerialization;
512
513    impl Serialize for RejectSerialization {
514        fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
515        where
516            S: serde::Serializer,
517        {
518            Err(S::Error::custom("rejected"))
519        }
520    }
521
522    fn empty_envelope() -> String {
523        format!(r#"{{"schemaVersion":{SCHEMA_VERSION},"data":[]}}"#)
524    }
525
526    fn schema_marker() -> String {
527        format!(r#""schemaVersion":{SCHEMA_VERSION}"#)
528    }
529
530    #[test]
531    fn slugs_envelope_lists_catalogue_with_known_families() {
532        let json = slugs();
533        assert!(json.contains(&schema_marker()));
534        assert!(json.contains(r#""canonical":"#));
535        assert!(json.contains(r#""family":"#));
536        // Guard against the silent `_ => "unknown"` degrade: every
537        // shipped slug must map to an explicit camelCase family.
538        assert!(
539            !json.contains(r#""family":"unknown""#),
540            "shipped catalogue leaked an unknown family: {json}"
541        );
542    }
543
544    #[test]
545    fn gaiji_resolutions_empty_envelope_for_plain_text() {
546        let snapshot = Document::new("no gaiji here").snapshot();
547        assert_eq!(gaiji(&snapshot), empty_envelope());
548    }
549
550    #[test]
551    fn gaiji_resolutions_emits_resolved_entry_in_source_coords() {
552        let snapshot = Document::new("※[#「々」]").snapshot();
553        let json = gaiji(&snapshot);
554        assert!(json.contains(&schema_marker()));
555        assert!(
556            json.contains(r#""span":{"start":0,"end":21}"#),
557            "json: {json}"
558        );
559        assert!(json.contains(r#""description":"々""#), "json: {json}");
560        assert!(json.contains(r#""resolved":"々""#), "json: {json}");
561        assert!(!json.contains(r#""mencode""#), "json: {json}");
562    }
563
564    #[test]
565    fn gaiji_entry_at_resolves_only_inside_span() {
566        let src = "あ※[#「々」]い";
567        let snapshot = Document::new(src).snapshot();
568        let inside = src.find('※').unwrap() + "※".len();
569        let at = gaiji_entry_at(&snapshot, inside).expect("inside gaiji");
570        assert_eq!(at.description, "々");
571        assert_eq!(at.resolved.as_deref(), Some("々"));
572        assert!(gaiji_entry_at(&snapshot, 0).is_none());
573    }
574
575    #[test]
576    fn schema_version_matches_the_wire_authority() {
577        assert_eq!(SCHEMA_VERSION, 3);
578    }
579
580    #[test]
581    fn empty_diagnostics_round_trip_envelope() {
582        let json = diagnostics(&[]);
583        assert_eq!(json, empty_envelope());
584    }
585
586    #[test]
587    #[should_panic(expected = "wire envelope serialization failed: rejected")]
588    fn serialization_failure_is_not_reported_as_an_empty_envelope() {
589        drop(serialize_envelope(&[RejectSerialization]));
590    }
591
592    #[test]
593    fn empty_nodes_round_trip_envelope() {
594        let doc = Document::new("plain");
595        let tree = doc.snapshot();
596        let json = nodes(&tree);
597        assert_eq!(json, empty_envelope());
598    }
599
600    #[test]
601    fn empty_pairs_round_trip_envelope() {
602        let doc = Document::new("plain");
603        let tree = doc.snapshot();
604        let json = pairs(&tree);
605        assert_eq!(json, empty_envelope());
606    }
607
608    #[test]
609    fn pua_collision_serialises_as_warning_kind() {
610        let doc = Document::new("abc\u{E001}def");
611        let tree = doc.snapshot();
612        let json = diagnostics(tree.diagnostics());
613        assert!(json.contains(&schema_marker()));
614        assert!(json.contains(r#""kind":"source_contains_pua""#));
615        assert!(json.contains(&format!(r#""codepoint":{}"#, '\u{E001}' as u32)));
616    }
617
618    #[test]
619    fn ruby_serialises_with_kind_ruby_in_nodes() {
620        let doc = Document::new("|青梅《おうめ》");
621        let tree = doc.snapshot();
622        let json = nodes(&tree);
623        assert!(json.contains(r#""kind":"ruby""#));
624        assert!(json.contains(&schema_marker()));
625    }
626
627    #[test]
628    fn ruby_serialises_in_pairs() {
629        let doc = Document::new("|青梅《おうめ》");
630        let tree = doc.snapshot();
631        let json = pairs(&tree);
632        assert!(json.contains(r#""kind":"ruby""#));
633        assert!(json.contains(r#""open":"#));
634        assert!(json.contains(r#""close":"#));
635    }
636
637    #[test]
638    fn pair_kind_camel_case_covers_all_known_kinds() {
639        use crate::PairKind;
640        assert_eq!(PairKind::Bracket.as_json_tag(), "bracket");
641        assert_eq!(PairKind::Ruby.as_json_tag(), "ruby");
642        assert_eq!(PairKind::AngleQuote.as_json_tag(), "angleQuote");
643        assert_eq!(PairKind::Tortoise.as_json_tag(), "tortoise");
644        assert_eq!(PairKind::Quote.as_json_tag(), "quote");
645    }
646
647    #[test]
648    fn container_pair_entries_projects_indent_open_close_offsets() {
649        // `[#ここから…字下げ] … [#ここで字下げ終わり]` is a paired
650        // indent container — `container_pair_entries` must project it, not
651        // return an empty vec.
652        let doc = Document::new("[#ここから2字下げ]\n本文\n[#ここで字下げ終わり]\n");
653        let tree = doc.snapshot();
654        let entries = container_pair_entries(&tree);
655        assert_eq!(
656            entries.len(),
657            1,
658            "expected exactly one indent container pair: {entries:?}"
659        );
660        let pair = &entries[0];
661        assert_eq!(pair.kind, "indent", "container kind: {pair:?}");
662        assert!(
663            pair.open.start < pair.close.start,
664            "open must precede close: {pair:?}"
665        );
666    }
667
668    #[cfg(feature = "schema")]
669    #[test]
670    fn schema_fns_return_titled_envelope_not_null() {
671        // Each `schema_*` fn must return its concrete JSON-Schema envelope
672        // (a Default::default() → `Value::Null` degrade would drop the
673        // title, the `schemaVersion` const, and the `data` array shape).
674        let cases = [
675            ("AozoraDiagnosticsEnvelope", schema_diagnostics()),
676            ("AozoraNodesEnvelope", schema_nodes()),
677            ("AozoraPairsEnvelope", schema_pairs()),
678            ("AozoraContainerPairsEnvelope", schema_container_pairs()),
679            ("AozoraGaijiEnvelope", schema_gaiji()),
680            ("AozoraSlugsEnvelope", schema_slugs()),
681        ];
682        for (title, value) in cases {
683            assert_eq!(
684                value["title"], title,
685                "schema envelope title mismatch: {value}"
686            );
687            assert_eq!(
688                value["type"], "object",
689                "schema envelope must be an object: {value}"
690            );
691            assert_eq!(
692                value["properties"]["schemaVersion"]["const"],
693                serde_json::json!(SCHEMA_VERSION),
694                "schemaVersion const must pin SCHEMA_VERSION: {value}"
695            );
696            assert_eq!(
697                value["properties"]["data"]["type"], "array",
698                "data property must be an array: {value}"
699            );
700        }
701    }
702
703    #[cfg(feature = "schema")]
704    #[test]
705    fn envelope_schema_wraps_item_in_versioned_envelope() {
706        // `envelope_schema` builds the shared `{schemaVersion, data:[…]}`
707        // wrapper; a Default::default() → `Value::Null` degrade would drop
708        // the passed title/description and the whole envelope structure.
709        let value = envelope_schema(
710            "CustomTitle",
711            "Custom description.",
712            schemars::schema_for!(Node),
713        );
714        assert_eq!(value["title"], "CustomTitle", "title: {value}");
715        assert_eq!(
716            value["description"], "Custom description.",
717            "description: {value}"
718        );
719        assert_eq!(
720            value["additionalProperties"], false,
721            "closed shape: {value}"
722        );
723        assert_eq!(
724            value["required"],
725            serde_json::json!(["schemaVersion", "data"]),
726            "required keys: {value}"
727        );
728        assert_eq!(
729            value["properties"]["schemaVersion"]["const"],
730            serde_json::json!(SCHEMA_VERSION),
731            "schemaVersion const: {value}"
732        );
733    }
734
735    #[test]
736    fn container_kind_wire_tags_via_as_json_tag() {
737        use crate::syntax::{BoutenKind, BoutenPosition, RegionFormat};
738        // `RegionFormat::as_json_tag` is the single authority on the
739        // container-pairs wire tag (no `_ => "unknown"` fallback —
740        // exhaustiveness is enforced in the syntax layer). The scope-specific
741        // `boutenRange` / `combineUprightRange` strings are preserved verbatim
742        // so a schema bump stays deliberate.
743        assert_eq!(RegionFormat::Bold { padded: false }.as_json_tag(), "bold");
744        assert_eq!(RegionFormat::Bold { padded: true }.as_json_tag(), "bold");
745        assert_eq!(
746            RegionFormat::Italic { padded: false }.as_json_tag(),
747            "italic"
748        );
749        assert_eq!(
750            RegionFormat::Italic { padded: true }.as_json_tag(),
751            "italic"
752        );
753        assert_eq!(
754            RegionFormat::Bouten {
755                kind: BoutenKind::Goma,
756                position: BoutenPosition::Right,
757            }
758            .as_json_tag(),
759            "boutenRange"
760        );
761    }
762}