Skip to main content

lex_extension/wire/
ast.rs

1//! Wire AST: the cross-version representation of Lex content.
2//!
3//! [`WireNode`] is a tagged enum (kind discriminator) covering all block
4//! kinds. Inline content uses [`WireInline`](crate::wire::WireInline).
5//!
6//! # Forward compatibility
7//!
8//! Adding a new `kind` to the wire format is a *breaking* change: the
9//! `wire_version` integer bumps, and old hosts/handlers reject the
10//! mismatched protocol at the `initialize` handshake. Within a single
11//! `wire_version`, the set of node kinds is closed.
12//!
13//! This is deliberately stricter than the per-string-enum forward-compat
14//! policy used for [`DiagnosticSeverity`](crate::wire::DiagnosticSeverity)
15//! and similar (which fall back to a documented default on unknown
16//! values). Block AST is structural; treating an unknown `kind` as
17//! "ignore me" silently drops document content, which is worse than
18//! refusing the document with a clear protocol-version diagnostic.
19//!
20//! On the Rust side, [`WireNode`] is `#[non_exhaustive]` so that adding a
21//! new variant in a future major-version release of this crate is not a
22//! breaking source-level change for downstream `match` consumers.
23
24use serde::{Deserialize, Serialize};
25
26use super::inline::WireInline;
27use super::range::Range;
28
29/// A block-level wire AST node. Wire form is a tagged object with `"kind"`
30/// selecting the variant, plus shared `range` and optional `origin` fields.
31///
32/// See the module-level docs for the forward-compatibility contract.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum WireNode {
37    Document {
38        range: Range,
39        #[serde(skip_serializing_if = "Option::is_none")]
40        origin: Option<String>,
41        children: Vec<WireNode>,
42    },
43    Session {
44        range: Range,
45        #[serde(skip_serializing_if = "Option::is_none")]
46        origin: Option<String>,
47        title: String,
48        #[serde(skip_serializing_if = "Option::is_none")]
49        marker: Option<String>,
50        children: Vec<WireNode>,
51    },
52    Definition {
53        range: Range,
54        #[serde(skip_serializing_if = "Option::is_none")]
55        origin: Option<String>,
56        subject: String,
57        children: Vec<WireNode>,
58    },
59    Paragraph {
60        range: Range,
61        #[serde(skip_serializing_if = "Option::is_none")]
62        origin: Option<String>,
63        inlines: Vec<WireInline>,
64    },
65    List {
66        range: Range,
67        #[serde(skip_serializing_if = "Option::is_none")]
68        origin: Option<String>,
69        marker_style: String,
70        items: Vec<WireListItem>,
71    },
72    Verbatim {
73        range: Range,
74        #[serde(skip_serializing_if = "Option::is_none")]
75        origin: Option<String>,
76        label: String,
77        params: serde_json::Value,
78        body_text: String,
79        /// The verbatim block's subject (the lead-in line, e.g.
80        /// `Code:`). Empty string for verbatim blocks that have no
81        /// subject (or for the placeholder shape used to flag
82        /// unsupported variants).
83        #[serde(default, skip_serializing_if = "String::is_empty")]
84        subject: String,
85        /// Rendering mode: `"inflow"` (content indented relative to
86        /// subject) or `"fullwidth"` (content at column 2). Defaults
87        /// to `"inflow"` on deserialise — matching the parser's
88        /// default mode — when the field is absent from the wire
89        /// payload.
90        #[serde(default = "default_verbatim_mode")]
91        mode: String,
92    },
93    Table {
94        range: Range,
95        #[serde(skip_serializing_if = "Option::is_none")]
96        origin: Option<String>,
97        caption: String,
98        header_rows: u32,
99        /// Per-column alignment. One entry per column; values are
100        /// `"left"`, `"center"`, `"right"`, or `""` (no alignment).
101        /// `column_aligns.length` defines the table's column count
102        /// and MUST equal the longest row in `rows`; rows MUST NOT
103        /// exceed this length. See `lex-extension-wire.lex` §2.2.
104        ///
105        /// Replaces the single whole-table `align: String` from
106        /// `wire_version: 1`. The old shape collapsed mixed-alignment
107        /// tables (e.g. a markdown pipe-table with `| :--- | :---: |`)
108        /// to a single alignment on the reverse codec.
109        column_aligns: Vec<String>,
110        rows: Vec<WireRow>,
111        #[serde(default, skip_serializing_if = "Vec::is_empty")]
112        footnotes: Vec<WireFootnote>,
113    },
114    /// Image media node. Produced by `on_resolve` for
115    /// `lex.media.image`-class verbatim labels; carries the same
116    /// data the host would otherwise flatten into `verbatim.params`.
117    /// New in `wire_version: 2` — see `lex-extension-wire.lex` §2.2.
118    Image {
119        range: Range,
120        #[serde(skip_serializing_if = "Option::is_none")]
121        origin: Option<String>,
122        src: String,
123        #[serde(default, skip_serializing_if = "String::is_empty")]
124        alt: String,
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        title: Option<String>,
127    },
128    /// Video media node. New in `wire_version: 2`.
129    Video {
130        range: Range,
131        #[serde(skip_serializing_if = "Option::is_none")]
132        origin: Option<String>,
133        src: String,
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        title: Option<String>,
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        poster: Option<String>,
138    },
139    /// Audio media node. New in `wire_version: 2`.
140    Audio {
141        range: Range,
142        #[serde(skip_serializing_if = "Option::is_none")]
143        origin: Option<String>,
144        src: String,
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        title: Option<String>,
147    },
148    Annotation {
149        range: Range,
150        #[serde(skip_serializing_if = "Option::is_none")]
151        origin: Option<String>,
152        label: String,
153        params: serde_json::Value,
154        /// `null` for marker-form annotations, a string for opaque-text
155        /// bodies, an object `{ "kind": "block", "children": [...] }` for
156        /// parsed-Lex bodies. See [`AnnotationBody`](super::ctx::AnnotationBody)
157        /// for the corresponding [`LabelCtx`](super::ctx::LabelCtx) shape.
158        body: serde_json::Value,
159    },
160    Blank {
161        range: Range,
162        #[serde(skip_serializing_if = "Option::is_none")]
163        origin: Option<String>,
164    },
165}
166
167impl WireNode {
168    /// The byte range this node spans in its origin source. Useful for
169    /// diagnostic attribution when the host only has the wire node
170    /// (e.g., the `on_format` dispatch path doesn't carry a separate
171    /// `NodeRef`).
172    pub fn range(&self) -> Range {
173        match self {
174            Self::Document { range, .. }
175            | Self::Session { range, .. }
176            | Self::Definition { range, .. }
177            | Self::Paragraph { range, .. }
178            | Self::List { range, .. }
179            | Self::Verbatim { range, .. }
180            | Self::Table { range, .. }
181            | Self::Image { range, .. }
182            | Self::Video { range, .. }
183            | Self::Audio { range, .. }
184            | Self::Annotation { range, .. }
185            | Self::Blank { range, .. } => *range,
186        }
187    }
188}
189
190/// One item inside a [`WireNode::List`].
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct WireListItem {
193    pub range: Range,
194    pub inlines: Vec<WireInline>,
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub children: Vec<WireNode>,
197}
198
199/// One row in a [`WireNode::Table`].
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct WireRow {
202    pub cells: Vec<WireTableCell>,
203}
204
205/// One cell in a [`WireRow`]. `inlines` holds the cell's content; merge
206/// markers (`>>`, `^^`) are surfaced as `colspan` / `rowspan` for downstream
207/// renderers.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct WireTableCell {
210    pub inlines: Vec<WireInline>,
211    #[serde(default = "one")]
212    pub colspan: u32,
213    #[serde(default = "one")]
214    pub rowspan: u32,
215}
216
217fn one() -> u32 {
218    1
219}
220
221fn default_verbatim_mode() -> String {
222    "inflow".to_string()
223}
224
225/// One footnote attached to a table.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct WireFootnote {
228    pub marker: String,
229    pub inlines: Vec<WireInline>,
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::wire::range::Position;
236
237    fn r(s_l: u32, s_c: u32, e_l: u32, e_c: u32) -> Range {
238        Range::new(Position::new(s_l, s_c), Position::new(e_l, e_c))
239    }
240
241    #[test]
242    fn paragraph_round_trips() {
243        let p = WireNode::Paragraph {
244            range: r(0, 0, 0, 5),
245            origin: None,
246            inlines: vec![WireInline::Text {
247                text: "hello".into(),
248            }],
249        };
250        let s = serde_json::to_string(&p).unwrap();
251        let back: WireNode = serde_json::from_str(&s).unwrap();
252        assert_eq!(back, p);
253    }
254
255    #[test]
256    fn paragraph_kind_in_serialized_form() {
257        let p = WireNode::Paragraph {
258            range: r(0, 0, 0, 5),
259            origin: None,
260            inlines: vec![],
261        };
262        let s = serde_json::to_string(&p).unwrap();
263        assert!(s.contains(r#""kind":"paragraph""#));
264    }
265
266    #[test]
267    fn document_with_children() {
268        let d = WireNode::Document {
269            range: r(0, 0, 10, 0),
270            origin: Some("doc.lex".into()),
271            children: vec![WireNode::Paragraph {
272                range: r(0, 0, 0, 3),
273                origin: None,
274                inlines: vec![WireInline::Text { text: "x".into() }],
275            }],
276        };
277        let s = serde_json::to_string(&d).unwrap();
278        assert!(s.contains(r#""origin":"doc.lex""#));
279        let back: WireNode = serde_json::from_str(&s).unwrap();
280        assert_eq!(back, d);
281    }
282
283    #[test]
284    fn annotation_with_lex_body() {
285        let a = WireNode::Annotation {
286            range: r(3, 0, 6, 0),
287            origin: None,
288            label: "acme.commenting".into(),
289            params: serde_json::json!({"role": "editor"}),
290            body: serde_json::json!({
291                "kind": "block",
292                "children": []
293            }),
294        };
295        let s = serde_json::to_string(&a).unwrap();
296        let back: WireNode = serde_json::from_str(&s).unwrap();
297        assert_eq!(back, a);
298    }
299
300    #[test]
301    fn verbatim_carries_label_and_body_text() {
302        let v = WireNode::Verbatim {
303            range: r(0, 0, 4, 0),
304            origin: None,
305            label: "rust".into(),
306            params: serde_json::json!({}),
307            body_text: "fn main() {}".into(),
308            subject: "Code:".into(),
309            mode: "inflow".into(),
310        };
311        let s = serde_json::to_string(&v).unwrap();
312        let back: WireNode = serde_json::from_str(&s).unwrap();
313        assert_eq!(back, v);
314    }
315
316    #[test]
317    fn verbatim_mode_field_defaults_to_inflow_on_deserialise() {
318        // A wire payload missing `mode` should round-trip as
319        // "inflow" — the documented default for older producers
320        // that don't emit the field.
321        let payload = r#"{
322            "kind":"verbatim",
323            "range":{"start":[0,0],"end":[4,0]},
324            "label":"rust",
325            "params":{},
326            "body_text":"x"
327        }"#;
328        let v: WireNode = serde_json::from_str(payload).unwrap();
329        match v {
330            WireNode::Verbatim {
331                ref mode,
332                ref subject,
333                ..
334            } => {
335                assert_eq!(mode, "inflow");
336                assert_eq!(subject, "");
337            }
338            _ => panic!("expected Verbatim"),
339        }
340    }
341
342    #[test]
343    fn table_round_trips_with_per_column_aligns() {
344        // Wire_version 2 (#583): `column_aligns` carries per-column
345        // alignment as a `Vec<String>` instead of the single-string
346        // whole-table summary of v1. Test the round-trip preserves
347        // mixed alignments — that was the regression that motivated
348        // the bump.
349        let t = WireNode::Table {
350            range: r(0, 0, 3, 0),
351            origin: None,
352            caption: "Demo".into(),
353            header_rows: 1,
354            column_aligns: vec!["left".into(), "center".into(), "right".into()],
355            rows: vec![
356                WireRow {
357                    cells: vec![
358                        WireTableCell {
359                            inlines: vec![WireInline::Text { text: "h1".into() }],
360                            colspan: 1,
361                            rowspan: 1,
362                        },
363                        WireTableCell {
364                            inlines: vec![WireInline::Text { text: "h2".into() }],
365                            colspan: 1,
366                            rowspan: 1,
367                        },
368                        WireTableCell {
369                            inlines: vec![WireInline::Text { text: "h3".into() }],
370                            colspan: 1,
371                            rowspan: 1,
372                        },
373                    ],
374                },
375                WireRow {
376                    cells: vec![
377                        WireTableCell {
378                            inlines: vec![WireInline::Text { text: "c1".into() }],
379                            colspan: 1,
380                            rowspan: 1,
381                        },
382                        WireTableCell {
383                            inlines: vec![WireInline::Text { text: "c2".into() }],
384                            colspan: 1,
385                            rowspan: 1,
386                        },
387                        WireTableCell {
388                            inlines: vec![WireInline::Text { text: "c3".into() }],
389                            colspan: 1,
390                            rowspan: 1,
391                        },
392                    ],
393                },
394            ],
395            footnotes: vec![],
396        };
397        let s = serde_json::to_string(&t).unwrap();
398        assert!(
399            s.contains(r#""column_aligns":["left","center","right"]"#),
400            "column_aligns must serialize as an array of per-column strings, got: {s}"
401        );
402        let back: WireNode = serde_json::from_str(&s).unwrap();
403        assert_eq!(back, t);
404    }
405
406    #[test]
407    fn image_round_trips() {
408        let i = WireNode::Image {
409            range: r(2, 0, 2, 30),
410            origin: None,
411            src: "chart.png".into(),
412            alt: "Q4 chart".into(),
413            title: Some("Quarter".into()),
414        };
415        let s = serde_json::to_string(&i).unwrap();
416        assert!(s.contains(r#""kind":"image""#));
417        assert!(s.contains(r#""src":"chart.png""#));
418        let back: WireNode = serde_json::from_str(&s).unwrap();
419        assert_eq!(back, i);
420    }
421
422    #[test]
423    fn image_marker_form_omits_empty_alt() {
424        let i = WireNode::Image {
425            range: r(0, 0, 0, 0),
426            origin: None,
427            src: "x.png".into(),
428            alt: String::new(),
429            title: None,
430        };
431        let s = serde_json::to_string(&i).unwrap();
432        assert!(!s.contains("alt"), "empty alt must be omitted: {s}");
433        assert!(!s.contains("title"), "None title must be omitted: {s}");
434    }
435
436    #[test]
437    fn video_round_trips_with_poster() {
438        let v = WireNode::Video {
439            range: r(0, 0, 0, 0),
440            origin: None,
441            src: "demo.mp4".into(),
442            title: Some("Demo".into()),
443            poster: Some("frame.png".into()),
444        };
445        let s = serde_json::to_string(&v).unwrap();
446        assert!(s.contains(r#""kind":"video""#));
447        assert!(s.contains(r#""poster":"frame.png""#));
448        let back: WireNode = serde_json::from_str(&s).unwrap();
449        assert_eq!(back, v);
450    }
451
452    #[test]
453    fn audio_round_trips() {
454        let a = WireNode::Audio {
455            range: r(0, 0, 0, 0),
456            origin: None,
457            src: "track.mp3".into(),
458            title: None,
459        };
460        let s = serde_json::to_string(&a).unwrap();
461        assert!(s.contains(r#""kind":"audio""#));
462        let back: WireNode = serde_json::from_str(&s).unwrap();
463        assert_eq!(back, a);
464    }
465
466    #[test]
467    fn list_with_items() {
468        let l = WireNode::List {
469            range: r(0, 0, 2, 0),
470            origin: None,
471            marker_style: "dash".into(),
472            items: vec![
473                WireListItem {
474                    range: r(0, 0, 0, 5),
475                    inlines: vec![WireInline::Text { text: "a".into() }],
476                    children: vec![],
477                },
478                WireListItem {
479                    range: r(1, 0, 1, 5),
480                    inlines: vec![WireInline::Text { text: "b".into() }],
481                    children: vec![],
482                },
483            ],
484        };
485        let s = serde_json::to_string(&l).unwrap();
486        let back: WireNode = serde_json::from_str(&s).unwrap();
487        assert_eq!(back, l);
488    }
489}