Skip to main content

bynk_check/
icu.rs

1//! message-bundles slice 3 (#878): the ICU MessageFormat placeholder
2//! mini-parser, plus the plain-`{name}` template scanner (`split_template`)
3//! it dispatches from. Self-contained and `bynk-syntax`-free by design
4//! (Decision B) — the whole ICU sub-grammar (`{name, plural, one {…} other
5//! {…}}`, `{name, select, …}`, `{name, number[, style]}`, `{name,
6//! date[, style]}`) lives entirely inside a `messages` template's `String`
7//! content, parsed here as plain `&str` and consumed by both the checker
8//! (`bynk-emit/src/project/validate.rs`) and the emitter
9//! (`emit_message_entry_renderer`). No `bynk-syntax` grammar/lexer/AST
10//! change backs this — a template stays one opaque `String` all the way
11//! through parsing.
12//!
13//! Lives in `bynk-check` (moved here in the compiler-pipeline-review's Wave
14//! 5, batch 5.1) rather than `bynk-emit`, since its only consumer besides
15//! the emitter is checker-side (`validate.rs`'s malformed-syntax pass) and
16//! it has no emitter-specific dependency of its own.
17//!
18//! Quoting: a bare `'` toggles a "quoted" region; `''` inside either mode
19//! means a literal `'` and doesn't toggle; while quoted, `{`/`}`/`,`/`#` are
20//! inert literal text. This is a deliberately narrower rule than full ICU
21//! MessageFormat's own quoting semantics — sufficient for this slice's fixed
22//! subset, not a general ICU implementation (Decision B/the proposal's named
23//! scope cuts).
24//!
25//! Explicitly unsupported, each with its own diagnosable
26//! [`IcuParseErrorKind`] rather than silent misbehaviour: `selectordinal`,
27//! `plural`'s `offset:`/`=N` exact-value arms, arbitrary CLDR skeletons
28//! beyond the fixed style keywords below, and nesting a second `{arg, …}`
29//! dispatch inside a sub-message (a sub-message is literal text + `#` only).
30
31use std::collections::BTreeMap;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct IcuPlaceholder<'a> {
35    pub name: &'a str,
36    pub kind: PlaceholderKind<'a>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PlaceholderKind<'a> {
41    Plural {
42        arms: Vec<(PluralCategory, Vec<SubSegment>)>,
43    },
44    Select {
45        arms: Vec<(&'a str, Vec<SubSegment>)>,
46    },
47    Number {
48        style: Option<NumberStyle>,
49    },
50    Date {
51        style: Option<DateStyle>,
52    },
53}
54
55/// The coarse comparison unit for cross-locale format agreement
56/// (`bynk.messages.format_mismatch`, `bynk-emit/src/project/validate.rs`) —
57/// arm/style content doesn't matter for agreement, only which of the five
58/// surface forms a placeholder uses. `Plain` covers the bare `{name}` fast
59/// path, which never calls into this parser at all.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum FormatKind {
62    Plain,
63    Plural,
64    Select,
65    Number,
66    Date,
67}
68
69impl FormatKind {
70    /// PR #879 review (finding 2): the lowercase surface vocabulary used
71    /// everywhere else (the diagnostics registry, ICU keywords themselves,
72    /// `bynk-ide::symbols::describe_messages`'s hover summary) — not
73    /// `{:?}`'s capitalized Rust enum name, which leaked into
74    /// `bynk.messages.format_mismatch`'s message text.
75    pub fn as_str(&self) -> &'static str {
76        match self {
77            Self::Plain => "plain",
78            Self::Plural => "plural",
79            Self::Select => "select",
80            Self::Number => "number",
81            Self::Date => "date",
82        }
83    }
84}
85
86impl<'a> PlaceholderKind<'a> {
87    pub fn format_kind(&self) -> FormatKind {
88        match self {
89            PlaceholderKind::Plural { .. } => FormatKind::Plural,
90            PlaceholderKind::Select { .. } => FormatKind::Select,
91            PlaceholderKind::Number { .. } => FormatKind::Number,
92            PlaceholderKind::Date { .. } => FormatKind::Date,
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum PluralCategory {
99    Zero,
100    One,
101    Two,
102    Few,
103    Many,
104    Other,
105}
106
107impl PluralCategory {
108    fn parse(s: &str) -> Option<Self> {
109        match s {
110            "zero" => Some(Self::Zero),
111            "one" => Some(Self::One),
112            "two" => Some(Self::Two),
113            "few" => Some(Self::Few),
114            "many" => Some(Self::Many),
115            "other" => Some(Self::Other),
116            _ => None,
117        }
118    }
119
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::Zero => "zero",
123            Self::One => "one",
124            Self::Two => "two",
125            Self::Few => "few",
126            Self::Many => "many",
127            Self::Other => "other",
128        }
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum NumberStyle {
134    Integer,
135    Percent,
136}
137
138impl NumberStyle {
139    pub fn as_str(&self) -> &'static str {
140        match self {
141            Self::Integer => "integer",
142            Self::Percent => "percent",
143        }
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum DateStyle {
149    Short,
150    Medium,
151    Long,
152    Full,
153}
154
155impl DateStyle {
156    pub fn as_str(&self) -> &'static str {
157        match self {
158            Self::Short => "short",
159            Self::Medium => "medium",
160            Self::Long => "long",
161            Self::Full => "full",
162        }
163    }
164}
165
166/// One piece of a `plural`/`select` arm's sub-message. Owned, not `&'a str`:
167/// ICU `''`-unescaping can shrink byte length, so a literal sub-segment
168/// cannot always borrow from the source template.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum SubSegment {
171    Literal(String),
172    /// Only valid inside a `plural` arm — substitutes the argument's own
173    /// value, run through `formatIcuNumber`.
174    Hash,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct IcuParseError {
179    /// Byte offset into the placeholder's `inner` text (the content between
180    /// the outer `{`/`}`, NOT the outer template) — rebased by the caller
181    /// against `MessageEntry::template_span` (message-bundles slice 3
182    /// Decision C).
183    pub offset: usize,
184    pub len: usize,
185    pub kind: IcuParseErrorKind,
186}
187
188impl IcuParseError {
189    fn at(offset: usize, len: usize, kind: IcuParseErrorKind) -> Self {
190        Self {
191            offset,
192            len: len.max(1),
193            kind,
194        }
195    }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum IcuParseErrorKind {
200    EmptyPlaceholderName,
201    MissingFormatKeyword,
202    UnknownFormatKeyword(String),
203    UnsupportedSelectordinal,
204    UnsupportedPluralOffset,
205    UnsupportedPluralExactValueArm(String),
206    UnknownStyleKeyword {
207        construct: &'static str,
208        found: String,
209    },
210    TrailingContentAfterStyle(String),
211    UnknownPluralCategory(String),
212    DuplicateArmKey(String),
213    MissingArmBody(String),
214    MissingOtherArm,
215    HashOutsidePluralArm,
216    NestedDispatchInSubMessage,
217    DanglingQuoteEscape,
218    UnbalancedArmBraces,
219}
220
221impl IcuParseErrorKind {
222    pub fn message(&self) -> String {
223        match self {
224            Self::EmptyPlaceholderName => "a message placeholder has no argument name before its first `,`".to_string(),
225            Self::MissingFormatKeyword => {
226                "a message placeholder with a `,` needs a format keyword (`plural`, `select`, `number`, or `date`) after the argument name".to_string()
227            }
228            Self::UnknownFormatKeyword(kw) => format!(
229                "unknown format keyword \"{kw}\"; expected `plural`, `select`, `number`, or `date`"
230            ),
231            Self::UnsupportedSelectordinal => {
232                "`selectordinal` is not supported; only `plural`, `select`, `number`, and `date` are".to_string()
233            }
234            Self::UnsupportedPluralOffset => {
235                "a `plural` placeholder's `offset:` is not supported".to_string()
236            }
237            Self::UnsupportedPluralExactValueArm(key) => format!(
238                "a `plural` placeholder's exact-value arm \"{key}\" is not supported; use a CLDR category (`zero`/`one`/`two`/`few`/`many`/`other`)"
239            ),
240            Self::UnknownStyleKeyword { construct, found } => format!(
241                "unknown `{construct}` style \"{found}\""
242            ),
243            Self::TrailingContentAfterStyle(found) => format!(
244                "unexpected content \"{found}\" after the style keyword"
245            ),
246            Self::UnknownPluralCategory(key) => format!(
247                "unknown plural category \"{key}\"; expected one of zero, one, two, few, many, other"
248            ),
249            Self::DuplicateArmKey(key) => format!(
250                "arm \"{key}\" is already declared in this placeholder"
251            ),
252            Self::MissingArmBody(key) => {
253                if key.is_empty() {
254                    "expected an arm body (`{…}`)".to_string()
255                } else {
256                    format!("arm \"{key}\" is missing its `{{…}}` body")
257                }
258            }
259            Self::MissingOtherArm => {
260                "a `plural`/`select` placeholder must declare an `other` arm".to_string()
261            }
262            Self::HashOutsidePluralArm => {
263                "`#` is only valid inside a `plural` arm's sub-message".to_string()
264            }
265            Self::NestedDispatchInSubMessage => {
266                "a sub-message cannot contain another `{…}` placeholder; only literal text and `#` are allowed"
267                    .to_string()
268            }
269            Self::DanglingQuoteEscape => {
270                "an unterminated `'` quote in this template".to_string()
271            }
272            Self::UnbalancedArmBraces => "an arm's `{…}` body is never closed".to_string(),
273        }
274    }
275}
276
277/// Scans `s` quote-aware (bare `'` toggles quoting, `''` is a literal `'`),
278/// tracking brace depth (unquoted `{`/`}` adjust it, starting from 0), and
279/// returns the byte offset of the first unquoted `,` found at depth 0 — the
280/// one primitive shared by every top-level split this parser needs (name vs.
281/// the rest; format keyword vs. its arms/style).
282fn find_top_level_comma(s: &str) -> Option<usize> {
283    let mut depth: i32 = 0;
284    let mut quoted = false;
285    let mut chars = s.char_indices().peekable();
286    while let Some((idx, ch)) = chars.next() {
287        match ch {
288            '\'' => {
289                if chars.peek().is_some_and(|&(_, c)| c == '\'') {
290                    chars.next();
291                } else {
292                    quoted = !quoted;
293                }
294            }
295            '{' if !quoted => depth += 1,
296            '}' if !quoted => depth -= 1,
297            ',' if !quoted && depth == 0 => return Some(idx),
298            _ => {}
299        }
300    }
301    None
302}
303
304/// Quote+depth-aware scan from just after a placeholder's opening `{`
305/// (`rest`), used only once emission-side detection (`split_template`) has
306/// already decided this is an ICU-dispatch placeholder (a `,` precedes the
307/// naive first `}`). Depth starts at 1 for the still-open outer brace;
308/// returns the byte offset of the true closing `}` (depth reaching 0),
309/// `None` if it never closes.
310pub fn find_icu_close(rest: &str) -> Option<usize> {
311    let mut depth: i32 = 1;
312    let mut quoted = false;
313    let mut chars = rest.char_indices().peekable();
314    while let Some((idx, ch)) = chars.next() {
315        match ch {
316            '\'' => {
317                if chars.peek().is_some_and(|&(_, c)| c == '\'') {
318                    chars.next();
319                } else {
320                    quoted = !quoted;
321                }
322            }
323            '{' if !quoted => depth += 1,
324            '}' if !quoted => {
325                depth -= 1;
326                if depth == 0 {
327                    return Some(idx);
328                }
329            }
330            _ => {}
331        }
332    }
333    None
334}
335
336/// Parses one arm-list (`plural`'s or `select`'s arms), starting at `text`
337/// (a slice of the placeholder's `inner`, already past the format keyword's
338/// own comma) whose absolute offset within `inner` is `base_offset` — every
339/// error this returns carries an offset already rebased to `inner`, not
340/// `text`. `allow_hash` gates whether a bare `#` is legal in an arm body
341/// (`plural` only); `is_plural` gates category-vocabulary checking and the
342/// `offset:`/`=N` exact-value rejections (`select` allows arbitrary keys).
343fn parse_arms(
344    text: &str,
345    base_offset: usize,
346    allow_hash: bool,
347    is_plural: bool,
348) -> Result<Vec<(String, Vec<SubSegment>)>, IcuParseError> {
349    let mut arms = Vec::new();
350    let mut i = 0usize;
351    loop {
352        while i < text.len() && text.as_bytes()[i].is_ascii_whitespace() {
353            i += 1;
354        }
355        if i >= text.len() {
356            break;
357        }
358        let key_start = i;
359        while i < text.len() {
360            let c = text[i..].chars().next().expect("i < text.len()");
361            if c.is_whitespace() || c == '{' {
362                break;
363            }
364            i += c.len_utf8();
365        }
366        let key = &text[key_start..i];
367        if key.is_empty() {
368            return Err(IcuParseError::at(
369                base_offset + i,
370                1,
371                IcuParseErrorKind::MissingArmBody(String::new()),
372            ));
373        }
374        if is_plural && key.starts_with("offset:") {
375            return Err(IcuParseError::at(
376                base_offset + key_start,
377                key.len(),
378                IcuParseErrorKind::UnsupportedPluralOffset,
379            ));
380        }
381        if is_plural && key.starts_with('=') {
382            return Err(IcuParseError::at(
383                base_offset + key_start,
384                key.len(),
385                IcuParseErrorKind::UnsupportedPluralExactValueArm(key.to_string()),
386            ));
387        }
388        if is_plural && PluralCategory::parse(key).is_none() {
389            return Err(IcuParseError::at(
390                base_offset + key_start,
391                key.len(),
392                IcuParseErrorKind::UnknownPluralCategory(key.to_string()),
393            ));
394        }
395        // PR #879 review (finding 1): a repeated arm key parses fine but
396        // emits a duplicate-property object literal (`{ "one": ..., "one":
397        // ... }`), which `tsc --strict` rejects (TS1117) — a generated-code
398        // failure the author never sees as a Bynk diagnostic. Caught here,
399        // before the body is even parsed, so it's reported at the key
400        // itself like every other arm-key error.
401        if arms.iter().any(|(k, _)| k == key) {
402            return Err(IcuParseError::at(
403                base_offset + key_start,
404                key.len(),
405                IcuParseErrorKind::DuplicateArmKey(key.to_string()),
406            ));
407        }
408        while i < text.len() && text.as_bytes()[i].is_ascii_whitespace() {
409            i += 1;
410        }
411        if i >= text.len() || text.as_bytes()[i] != b'{' {
412            return Err(IcuParseError::at(
413                base_offset + i,
414                1,
415                IcuParseErrorKind::MissingArmBody(key.to_string()),
416            ));
417        }
418        i += 1; // consume the arm's opening '{'
419        let body_start = i;
420        let (segs, consumed) =
421            parse_sub_message(&text[body_start..], base_offset + body_start, allow_hash)?;
422        arms.push((key.to_string(), segs));
423        i = body_start + consumed;
424    }
425    Ok(arms)
426}
427
428/// Parses one arm's sub-message body starting right after its opening `{`
429/// (`s`), quote-aware, stopping at the first unquoted `}`. Returns the
430/// parsed segments plus the number of bytes of `s` consumed, *including*
431/// that closing brace.
432fn parse_sub_message(
433    s: &str,
434    base_offset: usize,
435    allow_hash: bool,
436) -> Result<(Vec<SubSegment>, usize), IcuParseError> {
437    let mut segs = Vec::new();
438    let mut literal = String::new();
439    let mut quoted = false;
440    let mut chars = s.char_indices().peekable();
441    while let Some((idx, ch)) = chars.next() {
442        match ch {
443            '\'' => {
444                if chars.peek().is_some_and(|&(_, c)| c == '\'') {
445                    chars.next();
446                    literal.push('\'');
447                } else {
448                    quoted = !quoted;
449                }
450            }
451            '{' if !quoted => {
452                return Err(IcuParseError::at(
453                    base_offset + idx,
454                    1,
455                    IcuParseErrorKind::NestedDispatchInSubMessage,
456                ));
457            }
458            '}' if !quoted => {
459                if !literal.is_empty() {
460                    segs.push(SubSegment::Literal(std::mem::take(&mut literal)));
461                }
462                return Ok((segs, idx + 1));
463            }
464            '#' if !quoted && allow_hash => {
465                if !literal.is_empty() {
466                    segs.push(SubSegment::Literal(std::mem::take(&mut literal)));
467                }
468                segs.push(SubSegment::Hash);
469            }
470            '#' if !quoted => {
471                return Err(IcuParseError::at(
472                    base_offset + idx,
473                    1,
474                    IcuParseErrorKind::HashOutsidePluralArm,
475                ));
476            }
477            other => literal.push(other),
478        }
479    }
480    if quoted {
481        Err(IcuParseError::at(
482            base_offset + s.len(),
483            1,
484            IcuParseErrorKind::DanglingQuoteEscape,
485        ))
486    } else {
487        Err(IcuParseError::at(
488            base_offset,
489            s.len().max(1),
490            IcuParseErrorKind::UnbalancedArmBraces,
491        ))
492    }
493}
494
495fn ensure_other_present(
496    arms: &[(String, Vec<SubSegment>)],
497    base_offset: usize,
498    whole_len: usize,
499) -> Result<(), IcuParseError> {
500    if arms.iter().any(|(k, _)| k == "other") {
501        Ok(())
502    } else {
503        Err(IcuParseError::at(
504            base_offset,
505            whole_len,
506            IcuParseErrorKind::MissingOtherArm,
507        ))
508    }
509}
510
511/// Parses one placeholder's `inner` text (everything between the outer
512/// `{`/`}`, e.g. `"count, plural, one {# item} other {# items}"`) — the
513/// precondition for calling this at all is that `inner` contains at least
514/// one top-level comma (`split_template`'s own trigger for treating a
515/// placeholder as ICU-dispatch rather than a plain `{name}`).
516pub fn parse_icu_placeholder(inner: &str) -> Result<IcuPlaceholder<'_>, IcuParseError> {
517    let name_end = find_top_level_comma(inner).ok_or_else(|| {
518        IcuParseError::at(0, inner.len(), IcuParseErrorKind::MissingFormatKeyword)
519    })?;
520    let name = inner[..name_end].trim();
521    if name.is_empty() {
522        return Err(IcuParseError::at(
523            0,
524            name_end,
525            IcuParseErrorKind::EmptyPlaceholderName,
526        ));
527    }
528    let after_name = &inner[name_end + 1..];
529    let after_name_offset = name_end + 1;
530    let keyword_end = find_top_level_comma(after_name);
531    let (keyword_text, arms_or_style, arms_or_style_offset) = match keyword_end {
532        Some(k) => (
533            &after_name[..k],
534            Some(&after_name[k + 1..]),
535            after_name_offset + k + 1,
536        ),
537        None => (after_name, None, after_name_offset + after_name.len()),
538    };
539    let keyword = keyword_text.trim();
540    let keyword_offset = after_name_offset;
541
542    match keyword {
543        "plural" | "select" => {
544            let is_plural = keyword == "plural";
545            let (arms_text, arms_offset) = arms_or_style
546                .map(|a| (a, arms_or_style_offset))
547                .ok_or_else(|| {
548                    IcuParseError::at(
549                        after_name_offset,
550                        after_name.len(),
551                        IcuParseErrorKind::MissingArmBody(String::new()),
552                    )
553                })?;
554            let raw_arms = parse_arms(arms_text, arms_offset, is_plural, is_plural)?;
555            ensure_other_present(&raw_arms, arms_offset, arms_text.len())?;
556            if is_plural {
557                let arms = raw_arms
558                    .into_iter()
559                    .map(|(k, segs)| {
560                        (
561                            PluralCategory::parse(&k).expect("validated by parse_arms"),
562                            segs,
563                        )
564                    })
565                    .collect();
566                Ok(IcuPlaceholder {
567                    name,
568                    kind: PlaceholderKind::Plural { arms },
569                })
570            } else {
571                let arms = raw_arms
572                    .into_iter()
573                    .map(|(k, segs)| (inner_arm_key_slice(inner, arms_offset, &k), segs))
574                    .collect();
575                Ok(IcuPlaceholder {
576                    name,
577                    kind: PlaceholderKind::Select { arms },
578                })
579            }
580        }
581        "number" => {
582            let style =
583                parse_optional_style(arms_or_style, arms_or_style_offset, "number", |s| match s {
584                    "integer" => Some(NumberStyle::Integer),
585                    "percent" => Some(NumberStyle::Percent),
586                    _ => None,
587                })?;
588            Ok(IcuPlaceholder {
589                name,
590                kind: PlaceholderKind::Number { style },
591            })
592        }
593        "date" => {
594            let style =
595                parse_optional_style(arms_or_style, arms_or_style_offset, "date", |s| match s {
596                    "short" => Some(DateStyle::Short),
597                    "medium" => Some(DateStyle::Medium),
598                    "long" => Some(DateStyle::Long),
599                    "full" => Some(DateStyle::Full),
600                    _ => None,
601                })?;
602            Ok(IcuPlaceholder {
603                name,
604                kind: PlaceholderKind::Date { style },
605            })
606        }
607        "selectordinal" => Err(IcuParseError::at(
608            keyword_offset,
609            keyword_text.len(),
610            IcuParseErrorKind::UnsupportedSelectordinal,
611        )),
612        "" => Err(IcuParseError::at(
613            after_name_offset,
614            1,
615            IcuParseErrorKind::MissingFormatKeyword,
616        )),
617        other => Err(IcuParseError::at(
618            keyword_offset,
619            keyword_text.len(),
620            IcuParseErrorKind::UnknownFormatKeyword(other.to_string()),
621        )),
622    }
623}
624
625/// Re-slices `inner` at `arms_offset` to hand back a `&'a str` select-arm key
626/// (select allows arbitrary keys, so unlike `PluralCategory` there's no owned
627/// enum to convert to) — `k` (owned, from `parse_arms`) tells us the key's
628/// text and, by construction, its byte length; re-finding it in `inner`
629/// keeps `PlaceholderKind::Select`'s arm keys borrowed like `IcuPlaceholder`
630/// itself, rather than introducing an asymmetric owned-`String` key only for
631/// `select`.
632fn inner_arm_key_slice<'a>(inner: &'a str, _arms_offset: usize, k: &str) -> &'a str {
633    // `parse_arms` only ever slices arm keys directly out of the text it was
634    // given (never transforms them), so `k`'s bytes are a verbatim substring
635    // of `inner`; searching once for that exact substring recovers the
636    // borrow. Arm keys are non-empty (checked in `parse_arms`), so this
637    // always finds a match.
638    let start = inner
639        .find(k)
640        .expect("arm key is a verbatim substring of `inner`");
641    &inner[start..start + k.len()]
642}
643
644fn parse_optional_style<T>(
645    arms_or_style: Option<&str>,
646    arms_or_style_offset: usize,
647    construct: &'static str,
648    parse: impl Fn(&str) -> Option<T>,
649) -> Result<Option<T>, IcuParseError> {
650    let Some(s) = arms_or_style else {
651        return Ok(None);
652    };
653    if let Some(extra_comma) = find_top_level_comma(s) {
654        return Err(IcuParseError::at(
655            arms_or_style_offset + extra_comma + 1,
656            s.len().saturating_sub(extra_comma + 1).max(1),
657            IcuParseErrorKind::TrailingContentAfterStyle(s[extra_comma + 1..].trim().to_string()),
658        ));
659    }
660    let trimmed = s.trim();
661    parse(trimmed).map(Some).ok_or_else(|| {
662        IcuParseError::at(
663            arms_or_style_offset,
664            s.len(),
665            IcuParseErrorKind::UnknownStyleKeyword {
666                construct,
667                found: trimmed.to_string(),
668            },
669        )
670    })
671}
672
673// -- message-bundles slice 1 (#859) --
674
675/// A `{name}` placeholder (plain, or — message-bundles slice 3 (#878) — an
676/// ICU-dispatch placeholder whose `inner` text contains a `,`) or a run of
677/// literal text inside a message template. `offset` is the byte offset in
678/// the owning template where `inner`/the placeholder's content begins (right
679/// after the opening `{`) — used by slice 3's `icu_dispatch_placeholders`
680/// to rebase parse-error spans (Decision C).
681pub enum TemplateSegment<'a> {
682    Literal(&'a str),
683    Placeholder { offset: usize, inner: &'a str },
684}
685
686/// Compile-time string scan splitting a template into literal/placeholder
687/// segments (Decision D, message-bundles slice 1 — no new lexer/parser
688/// grammar; `{name}` is resolved by this Rust-side scan during lowering, not
689/// parsed as an expression). A `{` with no matching `}`, or an empty `{}`, is
690/// just literal text — malformed-placeholder checking is out of scope here.
691/// The name is taken verbatim, with no whitespace trimming: `{ name }` is a
692/// placeholder literally named `" name "`, which will never match a `params`
693/// key and so always renders as literal text (PR #872 review) — a real rough
694/// edge, left for a future slice rather than guessed at here.
695///
696/// message-bundles slice 3 (#878): if a `,` appears before the placeholder's
697/// naive (non-nested) closing `}`, it's treated as an ICU-dispatch
698/// placeholder instead of a plain one — the true close is then found via a
699/// quote+depth-aware scan (`find_icu_close`), since an ICU construct's
700/// arms can themselves contain nested `{…}` bodies. This is the *only*
701/// change from slice 1/2's behaviour: a template with no comma inside any
702/// `{…}` is scanned byte-for-byte identically to before.
703pub fn split_template(template: &str) -> Vec<TemplateSegment<'_>> {
704    let mut segments = Vec::new();
705    let mut literal_start = 0;
706    let mut i = 0;
707    while i < template.len() {
708        if template.as_bytes()[i] == b'{' {
709            let rest = &template[i + 1..];
710            let first_close = rest.find('}');
711            let first_comma = rest.find(',');
712            let is_icu = match (first_comma, first_close) {
713                (Some(c), Some(cl)) => c < cl,
714                (Some(_), None) => true,
715                (None, _) => false,
716            };
717            if is_icu {
718                if let Some(close_rel) = find_icu_close(rest) {
719                    let inner = &rest[..close_rel];
720                    if literal_start < i {
721                        segments.push(TemplateSegment::Literal(&template[literal_start..i]));
722                    }
723                    segments.push(TemplateSegment::Placeholder {
724                        offset: i + 1,
725                        inner,
726                    });
727                    i = i + 1 + close_rel + 1;
728                    literal_start = i;
729                    continue;
730                }
731                // No true close found anywhere — falls through to the
732                // one-char literal advance below, same as an unmatched `{`.
733            } else if let Some(rel_end) = first_close {
734                let name = &rest[..rel_end];
735                if !name.is_empty() && !name.contains('{') {
736                    if literal_start < i {
737                        segments.push(TemplateSegment::Literal(&template[literal_start..i]));
738                    }
739                    segments.push(TemplateSegment::Placeholder {
740                        offset: i + 1,
741                        inner: name,
742                    });
743                    i = i + 1 + rel_end + 1;
744                    literal_start = i;
745                    continue;
746                }
747            }
748        }
749        // Advance by one char (not one byte) to stay on UTF-8 boundaries.
750        i += template[i..]
751            .chars()
752            .next()
753            .map(char::len_utf8)
754            .unwrap_or(1);
755    }
756    if literal_start < template.len() || segments.is_empty() {
757        segments.push(TemplateSegment::Literal(&template[literal_start..]));
758    }
759    segments
760}
761
762/// message-bundles slice 2 (#874): a template's placeholder-name *set*, for
763/// cross-locale agreement checking (`bynk-emit/src/project/validate.rs`).
764/// Exposes only the name set, not `TemplateSegment` itself, keeping the
765/// checker's dependency on the emitter narrow. message-bundles slice 3
766/// (#878): an ICU-dispatch placeholder's name is its `inner` text up to the
767/// first top-level comma, trimmed — a plain placeholder's `inner` never
768/// contains a comma (by construction of `split_template`'s `is_icu` decision
769/// above), so it's returned verbatim, preserving the untrimmed-name quirk
770/// documented on `split_template` exactly as before.
771pub fn placeholder_names(template: &str) -> std::collections::BTreeSet<&str> {
772    split_template(template)
773        .into_iter()
774        .filter_map(|s| match s {
775            TemplateSegment::Placeholder { inner, .. } => Some(match inner.find(',') {
776                None => inner,
777                Some(idx) => inner[..idx].trim(),
778            }),
779            TemplateSegment::Literal(_) => None,
780        })
781        .collect()
782}
783
784/// Every ICU-dispatch placeholder in `template` — `(byte offset of `inner`
785/// within `template`, `inner` text)` — for the checker's malformed-syntax
786/// pass (`bynk-emit/src/project/validate.rs`). A placeholder only reaches
787/// here if `split_template` already decided it was ICU-dispatch (its `inner`
788/// contains a top-level comma); a plain `{name}` never does, by construction
789/// (proven in `split_template`'s own doc comment).
790pub fn icu_dispatch_placeholders(template: &str) -> Vec<(usize, &str)> {
791    split_template(template)
792        .into_iter()
793        .filter_map(|s| match s {
794            TemplateSegment::Placeholder { offset, inner } if inner.contains(',') => {
795                Some((offset, inner))
796            }
797            _ => None,
798        })
799        .collect()
800}
801
802/// Every placeholder's `(name, FormatKind)` in `template`, silently dropping
803/// any placeholder whose ICU parse fails — that failure is
804/// `check_entry_icu_syntax`'s job to report (once), not this pure helper's,
805/// so a caller comparing two locales' templates never double-reports a
806/// malformed one.
807pub fn template_format_kinds(template: &str) -> BTreeMap<&str, FormatKind> {
808    split_template(template)
809        .into_iter()
810        .filter_map(|s| match s {
811            TemplateSegment::Placeholder { inner, .. } => {
812                if let Some(comma) = inner.find(',') {
813                    let name = inner[..comma].trim();
814                    parse_icu_placeholder(inner)
815                        .ok()
816                        .map(|p| (name, p.kind.format_kind()))
817                } else {
818                    Some((inner, FormatKind::Plain))
819                }
820            }
821            _ => None,
822        })
823        .collect()
824}
825
826#[cfg(test)]
827mod icu_parser_tests {
828    use super::*;
829
830    fn parse(s: &str) -> IcuPlaceholder<'_> {
831        parse_icu_placeholder(s).unwrap_or_else(|e| panic!("expected Ok, got {e:?} for {s:?}"))
832    }
833
834    fn parse_err(s: &str) -> IcuParseErrorKind {
835        parse_icu_placeholder(s)
836            .expect_err("expected an error")
837            .kind
838    }
839
840    #[test]
841    fn plural_all_six_categories() {
842        let p = parse(
843            "n, plural, zero {none} one {#1} two {#2} few {#few} many {#many} other {#other}",
844        );
845        assert_eq!(p.name, "n");
846        let PlaceholderKind::Plural { arms } = p.kind else {
847            panic!("expected Plural")
848        };
849        let cats: Vec<_> = arms.iter().map(|(c, _)| *c).collect();
850        assert_eq!(
851            cats,
852            vec![
853                PluralCategory::Zero,
854                PluralCategory::One,
855                PluralCategory::Two,
856                PluralCategory::Few,
857                PluralCategory::Many,
858                PluralCategory::Other,
859            ]
860        );
861    }
862
863    #[test]
864    fn plural_hash_and_literal_segments() {
865        let p = parse("n, plural, one {# item} other {# items}");
866        let PlaceholderKind::Plural { arms } = p.kind else {
867            panic!("expected Plural")
868        };
869        assert_eq!(
870            arms[0].1,
871            vec![SubSegment::Hash, SubSegment::Literal(" item".to_string())]
872        );
873        assert_eq!(
874            arms[1].1,
875            vec![SubSegment::Hash, SubSegment::Literal(" items".to_string())]
876        );
877    }
878
879    #[test]
880    fn select_arbitrary_keys() {
881        let p = parse("g, select, male {He} female {She} other {They}");
882        assert_eq!(p.name, "g");
883        let PlaceholderKind::Select { arms } = p.kind else {
884            panic!("expected Select")
885        };
886        assert_eq!(arms[0].0, "male");
887        assert_eq!(arms[2].0, "other");
888    }
889
890    #[test]
891    fn number_bare_integer_percent() {
892        assert_eq!(
893            parse("n, number").kind,
894            PlaceholderKind::Number { style: None }
895        );
896        assert_eq!(
897            parse("n, number, integer").kind,
898            PlaceholderKind::Number {
899                style: Some(NumberStyle::Integer)
900            }
901        );
902        assert_eq!(
903            parse("n, number, percent").kind,
904            PlaceholderKind::Number {
905                style: Some(NumberStyle::Percent)
906            }
907        );
908    }
909
910    #[test]
911    fn date_bare_and_all_four_styles() {
912        assert_eq!(parse("d, date").kind, PlaceholderKind::Date { style: None });
913        for (kw, expect) in [
914            ("short", DateStyle::Short),
915            ("medium", DateStyle::Medium),
916            ("long", DateStyle::Long),
917            ("full", DateStyle::Full),
918        ] {
919            assert_eq!(
920                parse(&format!("d, date, {kw}")).kind,
921                PlaceholderKind::Date {
922                    style: Some(expect)
923                }
924            );
925        }
926    }
927
928    #[test]
929    fn quoting_doubled_quote_is_literal_apostrophe() {
930        let p = parse("n, plural, one {it''s one} other {it''s other}");
931        let PlaceholderKind::Plural { arms } = p.kind else {
932            panic!("expected Plural")
933        };
934        assert_eq!(arms[0].1, vec![SubSegment::Literal("it's one".to_string())]);
935    }
936
937    #[test]
938    fn quoting_escapes_literal_brace() {
939        let p = parse("n, plural, one {'{'} other {ok}");
940        let PlaceholderKind::Plural { arms } = p.kind else {
941            panic!("expected Plural")
942        };
943        assert_eq!(arms[0].1, vec![SubSegment::Literal("{".to_string())]);
944    }
945
946    #[test]
947    fn quoting_escapes_literal_hash() {
948        let p = parse("n, plural, one {'#'} other {ok}");
949        let PlaceholderKind::Plural { arms } = p.kind else {
950            panic!("expected Plural")
951        };
952        assert_eq!(arms[0].1, vec![SubSegment::Literal("#".to_string())]);
953    }
954
955    #[test]
956    fn multibyte_arm_body() {
957        let p = parse("n, select, other {caf\u{e9} \u{1f980}}");
958        let PlaceholderKind::Select { arms } = p.kind else {
959            panic!("expected Select")
960        };
961        assert_eq!(
962            arms[0].1,
963            vec![SubSegment::Literal("caf\u{e9} \u{1f980}".to_string())]
964        );
965    }
966
967    #[test]
968    fn err_empty_name() {
969        assert_eq!(
970            parse_err(", plural, other {x}"),
971            IcuParseErrorKind::EmptyPlaceholderName
972        );
973    }
974
975    #[test]
976    fn err_missing_format_keyword_no_second_comma() {
977        assert_eq!(parse_err("n"), IcuParseErrorKind::MissingFormatKeyword);
978    }
979
980    #[test]
981    fn err_unknown_format_keyword() {
982        assert_eq!(
983            parse_err("d, duration"),
984            IcuParseErrorKind::UnknownFormatKeyword("duration".to_string())
985        );
986    }
987
988    #[test]
989    fn err_selectordinal_unsupported() {
990        assert_eq!(
991            parse_err("rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}"),
992            IcuParseErrorKind::UnsupportedSelectordinal
993        );
994    }
995
996    #[test]
997    fn err_plural_offset_unsupported() {
998        assert_eq!(
999            parse_err("n, plural, offset:1 one {#} other {#}"),
1000            IcuParseErrorKind::UnsupportedPluralOffset
1001        );
1002    }
1003
1004    #[test]
1005    fn err_plural_exact_value_arm_unsupported() {
1006        assert_eq!(
1007            parse_err("n, plural, =0 {none} one {#} other {#}"),
1008            IcuParseErrorKind::UnsupportedPluralExactValueArm("=0".to_string())
1009        );
1010    }
1011
1012    #[test]
1013    fn err_unknown_plural_category() {
1014        assert_eq!(
1015            parse_err("n, plural, teen {x} other {y}"),
1016            IcuParseErrorKind::UnknownPluralCategory("teen".to_string())
1017        );
1018    }
1019
1020    #[test]
1021    fn err_duplicate_plural_arm() {
1022        // PR #879 review (finding 1): a repeated category compiled fine
1023        // before this fix and emitted a duplicate-property object literal
1024        // `tsc --strict` rejects (TS1117).
1025        assert_eq!(
1026            parse_err("n, plural, one {a} one {b} other {c}"),
1027            IcuParseErrorKind::DuplicateArmKey("one".to_string())
1028        );
1029    }
1030
1031    #[test]
1032    fn err_duplicate_select_arm() {
1033        assert_eq!(
1034            parse_err("g, select, male {a} male {b} other {c}"),
1035            IcuParseErrorKind::DuplicateArmKey("male".to_string())
1036        );
1037    }
1038
1039    #[test]
1040    fn err_missing_other_arm() {
1041        assert_eq!(
1042            parse_err("n, plural, one {# item}"),
1043            IcuParseErrorKind::MissingOtherArm
1044        );
1045    }
1046
1047    #[test]
1048    fn err_hash_outside_plural_arm() {
1049        assert_eq!(
1050            parse_err("g, select, male {# things} female {things} other {things}"),
1051            IcuParseErrorKind::HashOutsidePluralArm
1052        );
1053    }
1054
1055    #[test]
1056    fn err_nested_dispatch_in_sub_message() {
1057        assert_eq!(
1058            parse_err("n, plural, one {nested {m, number}} other {ok}"),
1059            IcuParseErrorKind::NestedDispatchInSubMessage
1060        );
1061    }
1062
1063    #[test]
1064    fn err_unbalanced_arm_braces() {
1065        // The `one` arm's own `{` is never closed at all — distinct from
1066        // `err_nested_dispatch_in_sub_message`'s case, where a *second*
1067        // unescaped `{` appears before any close.
1068        assert_eq!(
1069            parse_err("n, plural, one {# item"),
1070            IcuParseErrorKind::UnbalancedArmBraces
1071        );
1072    }
1073
1074    #[test]
1075    fn err_unknown_number_style() {
1076        assert_eq!(
1077            parse_err("n, number, currency"),
1078            IcuParseErrorKind::UnknownStyleKeyword {
1079                construct: "number",
1080                found: "currency".to_string(),
1081            }
1082        );
1083    }
1084
1085    #[test]
1086    fn err_unknown_date_style() {
1087        assert_eq!(
1088            parse_err("d, date, yyyy"),
1089            IcuParseErrorKind::UnknownStyleKeyword {
1090                construct: "date",
1091                found: "yyyy".to_string(),
1092            }
1093        );
1094    }
1095
1096    #[test]
1097    fn err_trailing_content_after_style() {
1098        assert_eq!(
1099            parse_err("n, number, integer, extra"),
1100            IcuParseErrorKind::TrailingContentAfterStyle("extra".to_string())
1101        );
1102    }
1103
1104    #[test]
1105    fn find_icu_close_handles_nested_arms_and_quoting() {
1106        let rest = "count, plural, one {'{'} other {#}} tail";
1107        let close = find_icu_close(rest).expect("should close");
1108        assert_eq!(&rest[..close], "count, plural, one {'{'} other {#}");
1109    }
1110
1111    #[test]
1112    fn template_format_kinds_mixed() {
1113        let kinds = template_format_kinds(
1114            "{a} {b, plural, one {#} other {#}} {c, select, x {x} other {o}} {d, number} {e, date}",
1115        );
1116        assert_eq!(kinds.get("a"), Some(&FormatKind::Plain));
1117        assert_eq!(kinds.get("b"), Some(&FormatKind::Plural));
1118        assert_eq!(kinds.get("c"), Some(&FormatKind::Select));
1119        assert_eq!(kinds.get("d"), Some(&FormatKind::Number));
1120        assert_eq!(kinds.get("e"), Some(&FormatKind::Date));
1121    }
1122
1123    #[test]
1124    fn icu_dispatch_placeholders_skips_plain_ones() {
1125        let found = icu_dispatch_placeholders("hi {name}, you have {n, plural, one {#} other {#}}");
1126        assert_eq!(found.len(), 1);
1127        assert!(found[0].1.starts_with("n, plural"));
1128    }
1129}