Skip to main content

lex_extension/wire/
host_node_kind.rs

1//! Wire-format names for AST node kinds that can host a label
2//! invocation (annotation header or verbatim block closing).
3//!
4//! Designed as the canonical source for two consumers that
5//! previously kept their own copies of the list:
6//!
7//! - The schema loader (in `lex-extension-host`): every entry in a
8//!   schema's `attaches_to` list must parse as one of these names.
9//! - The analysis / render walkers (in `lex-analysis` and
10//!   `lex-babel`): each labelled node is reported with one of these
11//!   names as its `attached_to` kind.
12//!
13//! Adoption lands in those crates' own PRs (the loader in #538;
14//! `lex-babel::render_dispatch` in this PR; `lex-analysis::label_dispatch`
15//! in #540). Once all three merge, the kind list lives only here —
16//! the duplicated allowed-set arrays in the original implementations
17//! are removed in lockstep.
18//!
19//! Keeping the list in one place prevents the consumers from
20//! drifting. A variant present in the walker but missing from the
21//! loader's whitelist would cause valid schemas to fail
22//! pre-validation; a typo in the walker would let invalid
23//! attachments slip through. Both classes of bug were observed in
24//! the original PR 4/7/8 implementations and are what motivated
25//! this shared type.
26//!
27//! # Stability
28//!
29//! Wire string forms are stable within a `WIRE_VERSION`. Adding a
30//! new kind is non-breaking on the host side (older schemas/walkers
31//! simply don't reference it); removing or renaming a kind is a
32//! `WIRE_VERSION` bump. The Rust enum is `#[non_exhaustive]` so new
33//! variants don't break exhaustive matches at consumer build time.
34
35use serde::{Deserialize, Serialize};
36
37/// One of the AST node kinds a label can attach to. See module-level
38/// docs for the rationale behind centralising this list.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41#[non_exhaustive]
42pub enum HostNodeKind {
43    Document,
44    Session,
45    Definition,
46    Paragraph,
47    List,
48    ListItem,
49    Verbatim,
50    Table,
51    Annotation,
52}
53
54impl HostNodeKind {
55    /// All variants in declaration order. The schema loader uses
56    /// this for "allowed kinds" error messages; tests use it to
57    /// exercise every variant.
58    pub const ALL: &'static [HostNodeKind] = &[
59        HostNodeKind::Document,
60        HostNodeKind::Session,
61        HostNodeKind::Definition,
62        HostNodeKind::Paragraph,
63        HostNodeKind::List,
64        HostNodeKind::ListItem,
65        HostNodeKind::Verbatim,
66        HostNodeKind::Table,
67        HostNodeKind::Annotation,
68    ];
69
70    /// Canonical wire string. Stable within `WIRE_VERSION = 1`.
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            HostNodeKind::Document => "document",
74            HostNodeKind::Session => "session",
75            HostNodeKind::Definition => "definition",
76            HostNodeKind::Paragraph => "paragraph",
77            HostNodeKind::List => "list",
78            HostNodeKind::ListItem => "list_item",
79            HostNodeKind::Verbatim => "verbatim",
80            HostNodeKind::Table => "table",
81            HostNodeKind::Annotation => "annotation",
82        }
83    }
84
85    /// Parse a wire kind name. Returns `None` for unknown names; the
86    /// schema loader uses this to reject `attaches_to` entries the
87    /// host doesn't understand.
88    pub fn parse(s: &str) -> Option<HostNodeKind> {
89        Self::ALL.iter().copied().find(|k| k.as_str() == s)
90    }
91
92    /// Canonical comma-separated list of allowed names — used in
93    /// schema-loader error messages and any other surface that
94    /// wants to enumerate the allowed set.
95    pub fn allowed_list() -> String {
96        Self::ALL
97            .iter()
98            .map(|k| k.as_str())
99            .collect::<Vec<_>>()
100            .join(", ")
101    }
102}
103
104impl std::fmt::Display for HostNodeKind {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn round_trip_through_str() {
116        for k in HostNodeKind::ALL {
117            assert_eq!(HostNodeKind::parse(k.as_str()), Some(*k));
118        }
119    }
120
121    #[test]
122    fn parse_unknown_returns_none() {
123        assert_eq!(HostNodeKind::parse("fragment"), None);
124        assert_eq!(HostNodeKind::parse(""), None);
125        assert_eq!(HostNodeKind::parse("Document"), None); // case-sensitive
126    }
127
128    #[test]
129    fn serialises_as_snake_case_string() {
130        let k = HostNodeKind::ListItem;
131        let s = serde_json::to_string(&k).unwrap();
132        assert_eq!(s, r#""list_item""#);
133        let back: HostNodeKind = serde_json::from_str(&s).unwrap();
134        assert_eq!(back, k);
135    }
136
137    #[test]
138    fn allowed_list_includes_every_variant() {
139        // Use exact-token membership rather than substring, otherwise
140        // `list_item` would falsely match the assertion for `list`.
141        let list = HostNodeKind::allowed_list();
142        let tokens: std::collections::HashSet<&str> = list.split(", ").collect();
143        for k in HostNodeKind::ALL {
144            assert!(
145                tokens.contains(k.as_str()),
146                "allowed_list missing variant `{}`: {list}",
147                k.as_str()
148            );
149        }
150        assert_eq!(tokens.len(), HostNodeKind::ALL.len());
151    }
152
153    #[test]
154    fn display_matches_as_str() {
155        assert_eq!(HostNodeKind::Paragraph.to_string(), "paragraph");
156    }
157}