Skip to main content

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}