1use serde::Serialize;
25
26use crate::encoding::gaiji;
27use crate::spec::SLUGS;
28use crate::{DiagnosticSource, Severity, Snapshot};
29
30pub const SCHEMA_VERSION: u32 = 3;
37
38#[must_use]
44pub fn diagnostics(diagnostics: &[crate::Diagnostic]) -> String {
45 serialize_envelope(&diagnostic_entries(diagnostics))
46}
47
48#[must_use]
52pub fn diagnostic_entries(diagnostics: &[crate::Diagnostic]) -> Vec<Diagnostic> {
53 diagnostics.iter().map(Diagnostic::from).collect()
54}
55
56#[must_use]
62pub fn nodes(snapshot: &Snapshot) -> String {
63 serialize_envelope(&node_entries(snapshot))
64}
65
66#[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#[must_use]
92pub fn pairs(snapshot: &Snapshot) -> String {
93 serialize_envelope(&pair_entries(snapshot))
94}
95
96#[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#[must_use]
120pub fn container_pairs(snapshot: &Snapshot) -> String {
121 serialize_envelope(&container_pair_entries(snapshot))
122}
123
124#[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#[must_use]
151pub fn slugs() -> String {
152 serialize_envelope(&slug_entries())
153}
154
155#[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#[must_use]
185pub fn gaiji(snapshot: &Snapshot) -> String {
186 serialize_envelope(&gaiji_entries(snapshot))
187}
188
189#[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#[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#[derive(Serialize)]
216#[serde(rename_all = "camelCase")]
217struct Envelope<'a, T> {
218 schema_version: u32,
219 data: &'a [T],
220}
221
222#[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#[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#[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#[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#[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#[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#[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 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#[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#[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 let codepoint = match d {
405 crate::Diagnostic::SourceContainsPua { codepoint, .. } => Some(u32::from(*codepoint)),
406 _ => None,
407 };
408 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#[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#[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#[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#[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#[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 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 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 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 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 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}