Skip to main content

docspec_oxa_writer/
lib.rs

1#![forbid(unsafe_code)]
2//! `DocSpec` event stream to `oxa.dev` JSON writer.
3//!
4//! This crate provides a streaming [`OxaWriter`] that implements [`EventSink`] to convert
5//! `DocSpec` event streams into `oxa.dev` JSON format.
6//!
7//! # Design
8//!
9//! The writer emits JSON tokens directly to the underlying `Write` as events arrive using
10//! `docspec-json` for streaming JSON output. Memory usage is constant regardless of document size.
11//!
12//! # Supported Events
13//!
14//! - `StartDocument` / `EndDocument` — document root
15//! - `StartParagraph` / `EndParagraph` — paragraph blocks
16//! - `Text` — inline text content
17//!
18//! # Example
19//!
20//! ```
21//! use docspec_core::{Event, EventSink, Result};
22//! use docspec_oxa_writer::OxaWriter;
23//!
24//! let mut buf = Vec::<u8>::new();
25//! let mut writer = OxaWriter::new(&mut buf);
26//! writer.handle_event(Event::StartDocument { id: None, language: None, metadata: None })?;
27//! writer.handle_event(Event::StartParagraph { alignment: None, id: None })?;
28//! writer.handle_event(Event::Text {
29//!     content: "Hello".to_string(),
30//! })?;
31//! writer.handle_event(Event::EndParagraph)?;
32//! writer.handle_event(Event::EndDocument)?;
33//! writer.finish()?;
34//! let json = String::from_utf8(buf).map_err(|err| docspec_core::Error::Other {
35//!     message: err.to_string(),
36//! })?;
37//! assert_eq!(
38//!     json,
39//!     r#"{"type":"Document","children":[{"type":"Paragraph","children":[{"type":"Text","value":"Hello"}]}]}"#
40//! );
41//! # Ok::<(), docspec_core::Error>(())
42//! ```
43
44use std::io::Write;
45
46use docspec_core::{Event, EventSink, Result};
47use docspec_json::{JsonEmitter, StrusonBackend};
48
49/// Streaming writer that converts a `DocSpec` event stream into `oxa.dev` JSON.
50///
51/// # Supported Events
52///
53/// - `StartDocument` / `EndDocument`
54/// - `StartParagraph` / `EndParagraph`
55/// - `Text` (content only, styles dropped)
56///
57/// All other events are silently ignored (no error, no panic).
58pub struct OxaWriter<W: Write> {
59    document_open: bool,
60    in_paragraph: bool,
61    json: JsonEmitter<StrusonBackend<W>>,
62}
63
64impl<W: Write> OxaWriter<W> {
65    /// Close the currently open paragraph, if any. No-op when no paragraph is open.
66    fn close_paragraph(&mut self) -> Result<()> {
67        if !self.in_paragraph {
68            return Ok(());
69        }
70        self.json.close_array()?;
71        self.json.close_object()?;
72        self.in_paragraph = false;
73        Ok(())
74    }
75
76    /// Create a new `OxaWriter` that writes to `writer`.
77    #[inline]
78    #[must_use]
79    pub fn new(writer: W) -> Self {
80        Self {
81            document_open: false,
82            in_paragraph: false,
83            json: JsonEmitter::new(StrusonBackend::new(writer)),
84        }
85    }
86}
87
88impl<W: Write> EventSink for OxaWriter<W> {
89    #[inline]
90    fn finish(self) -> Result<()> {
91        self.json.finish().map(|_| ())
92    }
93
94    #[inline]
95    fn handle_event(&mut self, event: Event) -> Result<()> {
96        match event {
97            Event::StartDocument { .. } => {
98                if self.document_open {
99                    return Ok(());
100                }
101                self.json.open_object()?;
102                self.json.key("type").value("Document")?;
103                self.json.key("children").open_array()?;
104                self.document_open = true;
105                Ok(())
106            }
107            Event::EndDocument => {
108                if !self.document_open {
109                    return Ok(());
110                }
111                self.close_paragraph()?;
112                self.json.close_array()?;
113                self.json.close_object()?;
114                self.document_open = false;
115                Ok(())
116            }
117            Event::StartParagraph { .. } => {
118                if !self.document_open || self.in_paragraph {
119                    return Ok(());
120                }
121                self.json.open_object()?;
122                self.json.key("type").value("Paragraph")?;
123                self.json.key("children").open_array()?;
124                self.in_paragraph = true;
125                Ok(())
126            }
127            Event::EndParagraph => self.close_paragraph(),
128            Event::Text { content } => {
129                if !self.in_paragraph {
130                    return Ok(());
131                }
132                self.json.object(|j| {
133                    j.key("type").value("Text")?;
134                    j.key("value").value(content.as_str())
135                })
136            }
137            Event::EndBlockQuote
138            | Event::EndCaption
139            | Event::EndDefinitionDetail
140            | Event::EndDefinitionList
141            | Event::EndDefinitionTerm
142            | Event::EndFootnote
143            | Event::EndHeading
144            | Event::EndLink
145            | Event::EndOrderedListItem
146            | Event::EndPreformatted
147            | Event::EndTable
148            | Event::EndTableCell
149            | Event::EndTableHeader
150            | Event::EndTableRow
151            | Event::EndTextStyle
152            | Event::EndUnorderedListItem
153            | Event::FootnoteRef { .. }
154            | Event::Image { .. }
155            | Event::LineBreak
156            | Event::SoftBreak
157            | Event::StartBlockQuote { .. }
158            | Event::StartCaption { .. }
159            | Event::StartDefinitionDetail { .. }
160            | Event::StartDefinitionList { .. }
161            | Event::StartDefinitionTerm { .. }
162            | Event::StartFootnote { .. }
163            | Event::StartHeading { .. }
164            | Event::StartLink { .. }
165            | Event::StartOrderedListItem { .. }
166            | Event::StartPreformatted { .. }
167            | Event::StartTable { .. }
168            | Event::StartTableCell { .. }
169            | Event::StartTableHeader { .. }
170            | Event::StartTableRow { .. }
171            | Event::StartTextStyle { .. }
172            | Event::StartUnorderedListItem { .. }
173            | Event::ThematicBreak { .. }
174            | _ => Ok(()),
175        }
176    }
177}