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}