brink_format/line.rs
1use alloc::string::String;
2use alloc::vec::Vec;
3
4/// The content of a single output line — either a plain string or a template
5/// with interpolation slots and plural selects.
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub enum LineContent {
8 Plain(String),
9 Template(LineTemplate),
10}
11
12bitflags::bitflags! {
13 /// Whitespace characteristics of a line, precomputed at compile time.
14 ///
15 /// Used by the output buffer to make filtering decisions (suppress
16 /// whitespace-only/empty content when there's no content yet) without
17 /// eagerly resolving deferred `LineRef` parts.
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 pub struct LineFlags: u8 {
20 /// The resolved content is entirely whitespace (but not empty).
21 const ALL_WS = 0b0100;
22 /// The resolved content is empty.
23 const EMPTY = 0b1000;
24 }
25}
26
27impl LineFlags {
28 /// Compute flags from a `LineContent`.
29 ///
30 /// For `Plain` content, flags are exact. For `Template` content, flags
31 /// are conservative: `Slot`/`Select` parts are assumed to produce
32 /// non-whitespace content.
33 pub fn from_content(content: &LineContent) -> Self {
34 match content {
35 LineContent::Plain(s) => Self::from_plain(s),
36 LineContent::Template(parts) => Self::from_template(parts),
37 }
38 }
39
40 /// Compute flags from a plain string.
41 pub fn from_plain(s: &str) -> Self {
42 if s.is_empty() {
43 return Self::EMPTY;
44 }
45 let mut flags = Self::empty();
46 if s.trim().is_empty() {
47 flags |= Self::ALL_WS;
48 }
49 flags
50 }
51
52 fn from_template(parts: &[LinePart]) -> Self {
53 if parts.is_empty() {
54 return Self::EMPTY;
55 }
56 let mut flags = Self::empty();
57
58 // ALL_WS: only true if every part is whitespace-only literals.
59 // Any Slot/Select means we can't guarantee all-whitespace. A Span
60 // is conservative too, for the same reason Slot/Select are — its
61 // `children` could resolve to anything once a `Slot` inside it
62 // does — even though a *literal-only* span's whitespace-ness
63 // could in principle be computed recursively, that refinement
64 // isn't needed for the runtime's current use of this flag
65 // (suppressing empty/whitespace-only output before real content
66 // has started) and conservative-false is always a safe answer.
67 let all_ws = parts.iter().all(|p| match p {
68 LinePart::Literal(s) => s.trim().is_empty(),
69 LinePart::Slot(_) | LinePart::Select { .. } | LinePart::Span { .. } => false,
70 });
71 if all_ws {
72 flags |= Self::ALL_WS;
73 }
74
75 flags
76 }
77}
78
79/// A sequence of literal and dynamic parts that compose an output line.
80pub type LineTemplate = Vec<LinePart>;
81
82/// One segment of a [`LineTemplate`].
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub enum LinePart {
85 /// A literal string fragment.
86 Literal(String),
87 /// A value interpolation slot (index into the evaluation stack snapshot).
88 Slot(u8),
89 /// A plural/keyword select over a slot value.
90 Select {
91 slot: u8,
92 variants: Vec<(SelectKey, String)>,
93 default: String,
94 },
95 /// `<name attr="v">…</name>` — an inline markup span
96 /// (`docs/prose-dialect-spec.md` §4.4, issue #1716). Genuinely nested,
97 /// mirroring `hir::ContentPart::Span`: the decoder enforces balance
98 /// structurally, so a mangled translation (unbalanced inline codes, a
99 /// classic TMS failure) becomes a decode error, not silent rendering
100 /// corruption. `children` is empty for a self-closing / point-marker
101 /// span (`<pause/>`, `<sfx name="bell"/>`, §8b.11).
102 ///
103 /// **Hash-transparent** (§4.4, RULED before any markup ships): `name`/
104 /// `attrs` never contribute to `source_hash` — only `children`'s own
105 /// text/slots do, recursively, the same way an `Interpolation`
106 /// contributes a `"{…}"` placeholder rather than its resolved value.
107 /// `Hello <wave>world</wave>` hashes identically to `Hello world`. See
108 /// `brink-ir`'s `lir::lower::recognize` — the one place that builds
109 /// this variant, and the one place hash-transparency is enforced.
110 Span {
111 name: String,
112 attrs: Vec<(String, String)>,
113 children: Vec<LinePart>,
114 },
115}
116
117/// The key for matching a branch in a [`LinePart::Select`].
118#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub enum SelectKey {
120 Cardinal(PluralCategory),
121 Ordinal(PluralCategory),
122 Exact(i32),
123 Keyword(String),
124}
125
126/// CLDR plural category.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub enum PluralCategory {
129 Zero,
130 One,
131 Two,
132 Few,
133 Many,
134 Other,
135}
136
137/// Trait for resolving plural categories at runtime.
138///
139/// Implementors provide locale-aware plural resolution. The `brink-intl` crate
140/// ships a batteries-included implementation backed by ICU4X baked data.
141pub trait PluralResolver {
142 /// Resolve the cardinal plural category for the given integer.
143 ///
144 /// `locale_override` allows overriding the resolver's default locale.
145 fn cardinal(&self, n: i64, locale_override: Option<&str>) -> PluralCategory;
146
147 /// Resolve the ordinal plural category for the given integer.
148 fn ordinal(&self, n: i64) -> PluralCategory;
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn select_part() -> LinePart {
156 LinePart::Select {
157 slot: 0,
158 variants: alloc::vec![(SelectKey::Exact(1), "one".to_string())],
159 default: "many".to_string(),
160 }
161 }
162
163 #[test]
164 fn bit_values_are_stable_for_persisted_brkt_compatibility() {
165 // `LineFlags` is persisted on the wire in the `.brkt` transcript
166 // format (`transcript.rs`'s `encode_part`/`decode_part`), unlike
167 // `.inkb` where it's recomputed at decode time. Removing
168 // `STARTS_WITH_WS`/`ENDS_WITH_WS` must not renumber the surviving
169 // bits, or a `.brkt` file written before this change would decode
170 // its old `ALL_WS`/`EMPTY` bits (0b0100/0b1000) as different flags
171 // under a newer reader. Pin the values so a future edit here has to
172 // consciously break this guarantee.
173 assert_eq!(LineFlags::ALL_WS.bits(), 0b0100);
174 assert_eq!(LineFlags::EMPTY.bits(), 0b1000);
175 }
176
177 #[test]
178 fn empty_plain_string_is_empty() {
179 assert_eq!(LineFlags::from_plain(""), LineFlags::EMPTY);
180 }
181
182 #[test]
183 fn whitespace_only_plain_string_is_all_ws() {
184 let flags = LineFlags::from_plain(" \t\n ");
185 assert!(flags.contains(LineFlags::ALL_WS));
186 assert!(!flags.contains(LineFlags::EMPTY));
187 }
188
189 #[test]
190 fn mixed_content_plain_string_has_no_flags() {
191 // Leading/trailing whitespace on otherwise non-whitespace content
192 // used to set STARTS_WITH_WS/ENDS_WITH_WS; those flags were removed
193 // (no production consumer — see #1444's follow-up scope note) so
194 // this case now carries no flags at all.
195 let flags = LineFlags::from_plain(" Hello world ");
196 assert!(flags.is_empty());
197 }
198
199 #[test]
200 fn empty_template_is_empty() {
201 assert_eq!(LineFlags::from_template(&[]), LineFlags::EMPTY);
202 }
203
204 #[test]
205 fn all_whitespace_literal_parts_are_all_ws() {
206 let parts = alloc::vec![
207 LinePart::Literal(" ".to_string()),
208 LinePart::Literal("\t".to_string()),
209 ];
210 let flags = LineFlags::from_template(&parts);
211 assert!(flags.contains(LineFlags::ALL_WS));
212 }
213
214 #[test]
215 fn a_slot_defeats_all_ws_even_if_every_literal_is_whitespace() {
216 // A Slot's resolved content is unknown at compile time, so ALL_WS
217 // must stay conservative (unset) even when every literal part
218 // present is whitespace-only.
219 let parts = alloc::vec![LinePart::Literal(" ".to_string()), LinePart::Slot(0)];
220 let flags = LineFlags::from_template(&parts);
221 assert!(!flags.contains(LineFlags::ALL_WS));
222 }
223
224 #[test]
225 fn a_select_defeats_all_ws() {
226 let parts = alloc::vec![select_part(), LinePart::Literal(" ".to_string())];
227 let flags = LineFlags::from_template(&parts);
228 assert!(!flags.contains(LineFlags::ALL_WS));
229 }
230
231 #[test]
232 fn mixed_content_template_has_no_flags() {
233 let parts = alloc::vec![
234 LinePart::Literal("Hello ".to_string()),
235 LinePart::Slot(0),
236 LinePart::Literal(" world".to_string()),
237 ];
238 let flags = LineFlags::from_template(&parts);
239 assert!(flags.is_empty());
240 }
241
242 #[test]
243 fn from_content_matches_from_template_for_templates() {
244 let parts = alloc::vec![LinePart::Slot(0), LinePart::Literal(" world".to_string())];
245 let content = LineContent::Template(parts.clone());
246 assert_eq!(
247 LineFlags::from_content(&content),
248 LineFlags::from_template(&parts)
249 );
250 }
251
252 #[test]
253 fn from_content_matches_from_plain_for_plain() {
254 let content = LineContent::Plain(" ".to_string());
255 assert_eq!(
256 LineFlags::from_content(&content),
257 LineFlags::from_plain(" ")
258 );
259 }
260}