Skip to main content

ferrocat_po/
borrowed.rs

1use std::borrow::Cow;
2
3use crate::line_state::{PoLineContext, PoLineState};
4use crate::scan::{
5    CommentKind, Keyword, LineKind, LineScanner, classify_line, find_quoted_bounds, has_byte,
6    parse_plural_index, split_once_byte, trim_ascii, trim_ascii_start, unrecognized_po_line,
7};
8use crate::text::{extract_quoted_bytes_cow, for_each_reference_token};
9use crate::utf8::input_slice_as_str;
10use crate::{Header, MsgStr, ParseError, ParsePosition, PoFile, PoItem, PoVec};
11
12/// Borrowed PO document that reuses slices from the original input whenever
13/// possible.
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct BorrowedPoFile<'a> {
16    /// File-level translator comments that appear before the header block.
17    pub comments: Vec<Cow<'a, str>>,
18    /// File-level extracted comments that appear before the header block.
19    pub extracted_comments: Vec<Cow<'a, str>>,
20    /// Parsed header entries from the leading empty `msgid` block.
21    pub headers: Vec<BorrowedHeader<'a>>,
22    /// Regular catalog items in source order.
23    pub items: Vec<BorrowedPoItem<'a>>,
24}
25
26impl BorrowedPoFile<'_> {
27    /// Converts the borrowed document into the owned [`PoFile`] representation.
28    #[must_use]
29    pub fn into_owned(self) -> PoFile {
30        PoFile {
31            comments: self.comments.into_iter().map(Cow::into_owned).collect(),
32            extracted_comments: self
33                .extracted_comments
34                .into_iter()
35                .map(Cow::into_owned)
36                .collect(),
37            headers: self
38                .headers
39                .into_iter()
40                .map(BorrowedHeader::into_owned)
41                .collect(),
42            items: self
43                .items
44                .into_iter()
45                .map(BorrowedPoItem::into_owned)
46                .collect(),
47        }
48    }
49}
50
51/// Borrowed header entry from the PO header block.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct BorrowedHeader<'a> {
54    /// Header name such as `Language` or `Plural-Forms`.
55    pub key: Cow<'a, str>,
56    /// Header value without the trailing newline.
57    pub value: Cow<'a, str>,
58}
59
60impl BorrowedHeader<'_> {
61    /// Converts the borrowed header into an owned [`Header`].
62    #[must_use]
63    pub fn into_owned(self) -> Header {
64        Header {
65            key: self.key.into_owned(),
66            value: self.value.into_owned(),
67        }
68    }
69}
70
71/// Borrowed gettext message entry.
72#[derive(Debug, Clone, PartialEq, Eq, Default)]
73pub struct BorrowedPoItem<'a> {
74    /// Source message identifier.
75    pub msgid: Cow<'a, str>,
76    /// Optional gettext message context.
77    pub msgctxt: Option<Cow<'a, str>>,
78    /// Source references such as `src/app.rs:10`.
79    pub references: PoVec<Cow<'a, str>>,
80    /// Optional plural source identifier.
81    pub msgid_plural: Option<Cow<'a, str>>,
82    /// Translation payload for the message.
83    pub msgstr: BorrowedMsgStr<'a>,
84    /// Translator comments attached to the item.
85    pub comments: PoVec<Cow<'a, str>>,
86    /// Extracted comments attached to the item.
87    pub extracted_comments: PoVec<Cow<'a, str>>,
88    /// Flags such as `fuzzy`.
89    pub flags: PoVec<Cow<'a, str>>,
90    /// Raw metadata lines that do not fit the dedicated fields.
91    pub metadata: PoVec<(Cow<'a, str>, Cow<'a, str>)>,
92    /// Whether the item is marked obsolete.
93    pub obsolete: bool,
94    /// Number of plural slots expected when the item is serialized.
95    pub nplurals: usize,
96}
97
98impl BorrowedPoItem<'_> {
99    fn new(nplurals: usize) -> Self {
100        Self {
101            nplurals,
102            ..Self::default()
103        }
104    }
105
106    /// Converts the borrowed item into an owned [`PoItem`].
107    #[must_use]
108    pub fn into_owned(self) -> PoItem {
109        PoItem {
110            msgid: self.msgid.into_owned(),
111            msgctxt: self.msgctxt.map(Cow::into_owned),
112            references: self.references.into_iter().map(Cow::into_owned).collect(),
113            msgid_plural: self.msgid_plural.map(Cow::into_owned),
114            msgstr: self.msgstr.into_owned(),
115            comments: self.comments.into_iter().map(Cow::into_owned).collect(),
116            extracted_comments: self
117                .extracted_comments
118                .into_iter()
119                .map(Cow::into_owned)
120                .collect(),
121            flags: self.flags.into_iter().map(Cow::into_owned).collect(),
122            metadata: self
123                .metadata
124                .into_iter()
125                .map(|(key, value)| (key.into_owned(), value.into_owned()))
126                .collect(),
127            obsolete: self.obsolete,
128            nplurals: self.nplurals,
129        }
130    }
131}
132
133/// Borrowed translation payload for a PO item.
134#[derive(Debug, Clone, PartialEq, Eq, Default)]
135pub enum BorrowedMsgStr<'a> {
136    /// No translation values are present.
137    #[default]
138    None,
139    /// Single translation string.
140    Singular(Cow<'a, str>),
141    /// Plural translation strings indexed by plural slot.
142    Plural(Vec<Cow<'a, str>>),
143}
144
145impl<'a> BorrowedMsgStr<'a> {
146    pub(crate) const fn is_empty(&self) -> bool {
147        matches!(self, Self::None)
148    }
149
150    /// Returns the number of translation values present, mirroring
151    /// [`MsgStr::len`].
152    pub(crate) fn len(&self) -> usize {
153        match self {
154            Self::None => 0,
155            Self::Singular(_) => 1,
156            Self::Plural(values) => values.len(),
157        }
158    }
159
160    /// Consumes the payload and yields its translation values in slot order,
161    /// mirroring [`MsgStr::iter`] without re-allocating the values.
162    pub(crate) fn into_values(self) -> std::vec::IntoIter<Cow<'a, str>> {
163        match self {
164            Self::None => Vec::new().into_iter(),
165            Self::Singular(value) => vec![value].into_iter(),
166            Self::Plural(values) => values.into_iter(),
167        }
168    }
169
170    pub(crate) fn set_slot(&mut self, plural_index: usize, value: Cow<'a, str>) {
171        match (&mut *self, plural_index) {
172            (Self::None, 0) => *self = Self::Singular(value),
173            (Self::Singular(existing), 0) => *existing = value,
174            (Self::Plural(values), 0) => {
175                if values.is_empty() {
176                    values.push(Cow::Borrowed(""));
177                }
178                values[0] = value;
179            }
180            _ => {
181                let values = self.promote_plural(plural_index);
182                values[plural_index] = value;
183            }
184        }
185    }
186
187    pub(crate) fn append_slot(&mut self, plural_index: usize, value: Cow<'a, str>) {
188        match (&mut *self, plural_index) {
189            (Self::None, 0) => *self = Self::Singular(value),
190            (Self::Singular(existing), 0) => existing.to_mut().push_str(value.as_ref()),
191            (Self::Plural(values), 0) => {
192                if values.is_empty() {
193                    values.push(Cow::Borrowed(""));
194                }
195                values[0].to_mut().push_str(value.as_ref());
196            }
197            _ => {
198                let values = self.promote_plural(plural_index);
199                values[plural_index].to_mut().push_str(value.as_ref());
200            }
201        }
202    }
203
204    pub(crate) fn ensure_singular(&mut self) {
205        if self.is_empty() {
206            *self = Self::Singular(Cow::Borrowed(""));
207        }
208    }
209
210    pub(crate) fn expand_singular_to_plural_width(&mut self, width: usize) {
211        match self {
212            Self::Singular(value) => {
213                let mut values = vec![std::mem::take(value)];
214                values.resize(width.max(1), Cow::Borrowed(""));
215                *self = Self::Plural(values);
216            }
217            Self::Plural(values) if values.len() == 1 => {
218                values.resize(width.max(1), Cow::Borrowed(""));
219            }
220            Self::None | Self::Plural(_) => {}
221        }
222    }
223
224    fn promote_plural(&mut self, plural_index: usize) -> &mut Vec<Cow<'a, str>> {
225        match self {
226            Self::None => {
227                *self = Self::Plural(Vec::with_capacity(2));
228                self.promote_plural(plural_index)
229            }
230            Self::Singular(value) => {
231                let value = std::mem::take(value);
232                *self = Self::Plural(vec![value]);
233                self.promote_plural(plural_index)
234            }
235            Self::Plural(values) => {
236                if values.len() <= plural_index {
237                    values.resize(plural_index + 1, Cow::Borrowed(""));
238                }
239                values
240            }
241        }
242    }
243
244    /// Converts the borrowed payload into an owned [`MsgStr`].
245    #[must_use]
246    pub fn into_owned(self) -> MsgStr {
247        match self {
248            Self::None => MsgStr::None,
249            Self::Singular(value) => MsgStr::Singular(value.into_owned()),
250            Self::Plural(values) => {
251                MsgStr::Plural(values.into_iter().map(Cow::into_owned).collect())
252            }
253        }
254    }
255}
256
257#[derive(Debug)]
258struct ParserState<'a> {
259    item: BorrowedPoItem<'a>,
260    header_entries: Vec<BorrowedHeader<'a>>,
261    /// Whether splitting the header block per physical fragment still yields the
262    /// same headers as decoding the whole header `msgstr` first (see
263    /// [`ParserState::push_header_fragment`]).
264    header_entries_faithful: bool,
265    /// Whether the previous header fragment left a header line unterminated, so
266    /// the next fragment continues it instead of starting a new header.
267    header_line_open: bool,
268    msgstr: BorrowedMsgStr<'a>,
269    line: PoLineState,
270}
271
272impl<'a> ParserState<'a> {
273    fn new(nplurals: usize) -> Self {
274        Self {
275            item: BorrowedPoItem::new(nplurals),
276            header_entries: Vec::new(),
277            header_entries_faithful: true,
278            header_line_open: false,
279            msgstr: BorrowedMsgStr::None,
280            line: PoLineState::default(),
281        }
282    }
283
284    fn reset(&mut self, nplurals: usize) {
285        *self = Self::new(nplurals);
286    }
287
288    fn set_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
289        self.msgstr.set_slot(plural_index, value);
290    }
291
292    fn append_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
293        self.msgstr.append_slot(plural_index, value);
294    }
295
296    /// Collects the headers of one physical header line while they can be kept
297    /// as slices of the input.
298    ///
299    /// Per-fragment splitting only matches the reference algorithm — decode the
300    /// whole header `msgstr`, then split it into lines — when every fragment
301    /// ends its header line, carries no escape other than `\n`, and starts no
302    /// obsolete (`#~`) line. Anything else clears
303    /// [`ParserState::header_entries_faithful`] so [`finish_item`] falls back to
304    /// the reference algorithm on the decoded `msgstr`.
305    ///
306    /// `starts_new_msgstr` marks a `msgstr` keyword line, which replaces the
307    /// header text collected so far instead of extending it.
308    fn push_header_fragment(&mut self, line_bytes: &'a [u8], starts_new_msgstr: bool) {
309        if starts_new_msgstr {
310            self.header_entries.clear();
311            self.header_line_open = false;
312        }
313        if !self.header_entries_faithful {
314            return;
315        }
316
317        let Some((start, end)) = find_quoted_bounds(line_bytes) else {
318            self.header_entries_faithful = false;
319            return;
320        };
321        let raw = &line_bytes[start..end];
322
323        if self.header_line_open
324            || !header_fragment_is_borrowable(raw)
325            || !push_borrowed_header_segments(raw, &mut self.header_entries)
326        {
327            self.header_entries_faithful = false;
328            return;
329        }
330
331        self.header_line_open = !raw.is_empty() && !raw.ends_with(br"\n");
332    }
333
334    /// Returns the decoded header `msgstr`, mirroring the owned parser.
335    fn header_msgstr(&self) -> &str {
336        match &self.msgstr {
337            BorrowedMsgStr::None => "",
338            BorrowedMsgStr::Singular(value) => value.as_ref(),
339            BorrowedMsgStr::Plural(values) => values.first().map_or("", Cow::as_ref),
340        }
341    }
342
343    fn materialize_msgstr(&mut self) {
344        debug_assert!(self.item.msgstr.is_empty());
345        self.item.msgstr = std::mem::take(&mut self.msgstr);
346    }
347}
348
349#[derive(Debug, Clone, Copy)]
350struct BorrowedLine<'a> {
351    trimmed: &'a [u8],
352    obsolete: bool,
353    position: ParsePosition,
354}
355
356/// Parses PO content into a borrowed representation.
357///
358/// This parser keeps references into `input` for fields that do not need
359/// unescaping, which reduces allocations compared with [`crate::parse_po`].
360/// LF, CRLF, and bare CR line endings are accepted.
361///
362/// # Errors
363///
364/// Returns [`ParseError`] when the input is not valid PO syntax.
365pub fn parse_po_borrowed(input: &str) -> Result<BorrowedPoFile<'_>, ParseError> {
366    let input = input.strip_prefix('\u{feff}').unwrap_or(input);
367
368    let mut file = BorrowedPoFile::default();
369    file.items.reserve((input.len() / 96).max(1));
370    let mut current_nplurals = 2;
371    let mut state = ParserState::new(current_nplurals);
372
373    for line in LineScanner::new(input.as_bytes()) {
374        parse_line(
375            BorrowedLine {
376                trimmed: line.trimmed,
377                obsolete: line.obsolete,
378                position: line.position,
379            },
380            &mut state,
381            &mut file,
382            &mut current_nplurals,
383        )?;
384    }
385
386    finish_item(&mut state, &mut file, &mut current_nplurals);
387
388    Ok(file)
389}
390
391fn parse_line<'a>(
392    line: BorrowedLine<'a>,
393    state: &mut ParserState<'a>,
394    file: &mut BorrowedPoFile<'a>,
395    current_nplurals: &mut usize,
396) -> Result<(), ParseError> {
397    match classify_line(line.trimmed) {
398        LineKind::Continuation => {
399            append_continuation(line.trimmed, line.obsolete, line.position, state)?;
400            Ok(())
401        }
402        LineKind::Comment(kind) => {
403            parse_comment_line(line.trimmed, kind, state, file, current_nplurals);
404            Ok(())
405        }
406        LineKind::Keyword(keyword) => parse_keyword_line(
407            line.trimmed,
408            line.obsolete,
409            line.position,
410            keyword,
411            state,
412            file,
413            current_nplurals,
414        ),
415        LineKind::Other => Err(unrecognized_po_line(line.position)),
416    }
417}
418
419fn parse_comment_line<'a>(
420    line_bytes: &'a [u8],
421    kind: CommentKind,
422    state: &mut ParserState<'a>,
423    file: &mut BorrowedPoFile<'a>,
424    current_nplurals: &mut usize,
425) {
426    finish_item(state, file, current_nplurals);
427
428    match kind {
429        CommentKind::Reference => {
430            let reference_line = trimmed_str(&line_bytes[2..]);
431            for_each_reference_token(reference_line, |token| {
432                state.item.references.push(token);
433            });
434        }
435        CommentKind::Flags => {
436            for flag in trimmed_str(&line_bytes[2..]).split(',') {
437                state.item.flags.push(Cow::Borrowed(flag.trim()));
438            }
439        }
440        CommentKind::Extracted => state
441            .item
442            .extracted_comments
443            .push(trimmed_cow(&line_bytes[2..])),
444        CommentKind::Metadata => {
445            let trimmed = trim_ascii(&line_bytes[2..]);
446            if let Some(value_bytes) = ferrocat_mt_metadata_value(trimmed) {
447                state
448                    .item
449                    .metadata
450                    .push((Cow::Borrowed("ferrocat-mt"), trimmed_cow(value_bytes)));
451            } else if let Some((key_bytes, value_bytes)) = split_once_byte(trimmed, b':') {
452                let key = trimmed_cow(key_bytes);
453                if !key.is_empty() {
454                    let value = trimmed_cow(value_bytes);
455                    state.item.metadata.push((key, value));
456                }
457            }
458        }
459        CommentKind::Translator => state.item.comments.push(trimmed_cow(&line_bytes[1..])),
460        CommentKind::Other => {}
461    }
462}
463
464fn ferrocat_mt_metadata_value(trimmed: &[u8]) -> Option<&[u8]> {
465    const KEY: &[u8] = b"ferrocat-mt";
466    let rest = trimmed.strip_prefix(KEY)?;
467    rest.first()
468        .is_some_and(u8::is_ascii_whitespace)
469        .then(|| trim_ascii(rest))
470}
471
472fn parse_keyword_line<'a>(
473    line_bytes: &'a [u8],
474    obsolete: bool,
475    position: ParsePosition,
476    keyword: Keyword,
477    state: &mut ParserState<'a>,
478    file: &mut BorrowedPoFile<'a>,
479    current_nplurals: &mut usize,
480) -> Result<(), ParseError> {
481    match keyword {
482        Keyword::IdPlural => {
483            state
484                .line
485                .mark_keyword(PoLineContext::IdPlural, 0, obsolete);
486            state.item.msgid_plural = Some(at_line_position(
487                extract_quoted_bytes_cow(line_bytes),
488                position,
489            )?);
490        }
491        Keyword::Id => {
492            finish_item(state, file, current_nplurals);
493            state.line.mark_keyword(PoLineContext::Id, 0, obsolete);
494            state.item.msgid = at_line_position(extract_quoted_bytes_cow(line_bytes), position)?;
495        }
496        Keyword::Str => {
497            let plural_index = parse_plural_index(line_bytes).unwrap_or(0);
498            state
499                .line
500                .mark_keyword(PoLineContext::Str, plural_index, obsolete);
501            state.set_msgstr(
502                plural_index,
503                at_line_position(extract_quoted_bytes_cow(line_bytes), position)?,
504            );
505            if is_header_candidate(state) {
506                state.push_header_fragment(line_bytes, true);
507            }
508        }
509        Keyword::Ctxt => {
510            finish_item(state, file, current_nplurals);
511            state.line.mark_keyword(PoLineContext::Ctxt, 0, obsolete);
512            state.item.msgctxt = Some(at_line_position(
513                extract_quoted_bytes_cow(line_bytes),
514                position,
515            )?);
516        }
517    }
518
519    Ok(())
520}
521
522fn append_continuation<'a>(
523    line_bytes: &'a [u8],
524    obsolete: bool,
525    position: ParsePosition,
526    state: &mut ParserState<'a>,
527) -> Result<(), ParseError> {
528    state.line.mark_continuation(obsolete);
529    let value = at_line_position(extract_quoted_bytes_cow(line_bytes), position)?;
530
531    match state.line.context() {
532        Some(PoLineContext::Str) => {
533            state.append_msgstr(state.line.plural_index(), value);
534            if is_header_candidate(state) {
535                state.push_header_fragment(line_bytes, false);
536            }
537        }
538        Some(PoLineContext::Id) => state.item.msgid.to_mut().push_str(value.as_ref()),
539        Some(PoLineContext::IdPlural) => {
540            let target = state.item.msgid_plural.get_or_insert(Cow::Borrowed(""));
541            target.to_mut().push_str(value.as_ref());
542        }
543        Some(PoLineContext::Ctxt) => {
544            let target = state.item.msgctxt.get_or_insert(Cow::Borrowed(""));
545            target.to_mut().push_str(value.as_ref());
546        }
547        None => {}
548    }
549
550    Ok(())
551}
552
553#[inline]
554fn at_line_position<T>(
555    result: Result<T, ParseError>,
556    position: ParsePosition,
557) -> Result<T, ParseError> {
558    result.map_err(|error| error.with_position_if_missing(position))
559}
560
561fn finish_item<'a>(
562    state: &mut ParserState<'a>,
563    file: &mut BorrowedPoFile<'a>,
564    current_nplurals: &mut usize,
565) {
566    if !state.line.has_keyword() {
567        return;
568    }
569
570    if state.item.msgid.is_empty() && !is_header_state(state) {
571        return;
572    }
573
574    if state.line.is_obsolete_item() {
575        state.item.obsolete = true;
576    }
577
578    if is_header_state(state) && file.headers.is_empty() && file.items.is_empty() {
579        file.comments = std::mem::take(&mut state.item.comments).into_vec();
580        file.extracted_comments = std::mem::take(&mut state.item.extracted_comments).into_vec();
581        if state.header_entries_faithful {
582            file.headers = std::mem::take(&mut state.header_entries);
583        } else {
584            parse_owned_headers(state.header_msgstr(), &mut file.headers);
585        }
586        *current_nplurals = parse_nplurals(&file.headers).unwrap_or(2);
587        state.reset(*current_nplurals);
588        return;
589    }
590
591    state.materialize_msgstr();
592
593    state.item.msgstr.ensure_singular();
594    if state.item.msgid_plural.is_some() {
595        state
596            .item
597            .msgstr
598            .expand_singular_to_plural_width(state.item.nplurals);
599    }
600
601    state.item.nplurals = *current_nplurals;
602    file.items.push(std::mem::take(&mut state.item));
603    state.reset(*current_nplurals);
604}
605
606fn is_header_state(state: &ParserState<'_>) -> bool {
607    state.item.msgid.is_empty()
608        && state.item.msgctxt.is_none()
609        && state.item.msgid_plural.is_none()
610        && !state.msgstr.is_empty()
611}
612
613fn is_header_candidate(state: &ParserState<'_>) -> bool {
614    state.item.msgid.is_empty()
615        && state.item.msgctxt.is_none()
616        && state.item.msgid_plural.is_none()
617        && state.line.plural_index() == 0
618}
619
620/// Splits one borrowable header fragment into `\n`-terminated header lines.
621///
622/// Returns `false` when a segment starts an obsolete (`#~`) line, which the
623/// reference line splitter strips but this fast path cannot.
624fn push_borrowed_header_segments<'a>(raw: &'a [u8], out: &mut Vec<BorrowedHeader<'a>>) -> bool {
625    let mut start = 0usize;
626    let mut index = 0usize;
627
628    while index < raw.len() {
629        if raw[index] == b'\\' && raw.get(index + 1) == Some(&b'n') {
630            if !push_borrowed_header_segment(&raw[start..index], out) {
631                return false;
632            }
633            index += 2;
634            start = index;
635            continue;
636        }
637        index += 1;
638    }
639
640    push_borrowed_header_segment(&raw[start..], out)
641}
642
643fn push_borrowed_header_segment<'a>(segment: &'a [u8], out: &mut Vec<BorrowedHeader<'a>>) -> bool {
644    let segment = trim_ascii_start(segment);
645    if segment.is_empty() {
646        return true;
647    }
648    if segment.starts_with(b"#~") {
649        return false;
650    }
651    if let Some((key_bytes, value_bytes)) = split_once_byte(segment, b':') {
652        out.push(BorrowedHeader {
653            key: trimmed_cow(key_bytes),
654            value: trimmed_cow(value_bytes),
655        });
656    }
657    true
658}
659
660/// Reference header parsing: split the already decoded header `msgstr` into
661/// lines the same way the file itself is scanned. Used whenever the borrowed
662/// fast path cannot reproduce that result exactly.
663fn parse_owned_headers<'a>(raw: &str, out: &mut Vec<BorrowedHeader<'a>>) {
664    for line in LineScanner::new(raw.as_bytes()) {
665        if let Some((key_bytes, value_bytes)) = split_once_byte(line.trimmed, b':') {
666            out.push(BorrowedHeader {
667                key: Cow::Owned(trimmed_str(key_bytes).to_owned()),
668                value: Cow::Owned(trimmed_str(value_bytes).to_owned()),
669            });
670        }
671    }
672}
673
674fn header_fragment_is_borrowable(raw: &[u8]) -> bool {
675    let mut index = 0usize;
676    while index < raw.len() {
677        if raw[index] == b'\\' {
678            if raw.get(index + 1) != Some(&b'n') {
679                return false;
680            }
681            index += 2;
682            continue;
683        }
684        index += 1;
685    }
686    !has_byte(b'"', raw)
687}
688
689fn parse_nplurals(headers: &[BorrowedHeader<'_>]) -> Option<usize> {
690    let plural_forms = headers
691        .iter()
692        .find(|header| header.key.as_ref() == "Plural-Forms")?
693        .value
694        .as_bytes();
695    let mut rest = plural_forms;
696
697    while !rest.is_empty() {
698        let (part, next) = match split_once_byte(rest, b';') {
699            Some((part, tail)) => (part, tail),
700            None => (rest, &b""[..]),
701        };
702        let trimmed = trim_ascii(part);
703        if let Some((key, value)) = split_once_byte(trimmed, b'=')
704            && trim_ascii(key) == b"nplurals"
705            && let value = bytes_to_str(trim_ascii(value))
706            && let Ok(parsed) = value.parse::<usize>()
707        {
708            return Some(parsed);
709        }
710        rest = next;
711    }
712
713    None
714}
715
716fn bytes_to_str(bytes: &[u8]) -> &str {
717    input_slice_as_str(bytes)
718}
719
720fn trimmed_str(bytes: &[u8]) -> &str {
721    bytes_to_str(trim_ascii(bytes))
722}
723
724fn trimmed_cow(bytes: &[u8]) -> Cow<'_, str> {
725    Cow::Borrowed(trimmed_str(bytes))
726}
727
728#[cfg(test)]
729mod tests {
730    use std::borrow::Cow;
731
732    use crate::MsgStr;
733
734    use super::{BorrowedMsgStr, parse_po_borrowed};
735
736    #[test]
737    fn borrowed_msgstr_helpers_cover_slot_promotion_and_plural_width() {
738        assert_eq!(BorrowedMsgStr::None.into_owned(), MsgStr::None);
739
740        let mut appended_none = BorrowedMsgStr::None;
741        appended_none.append_slot(0, Cow::Borrowed("one"));
742        assert_eq!(
743            appended_none,
744            BorrowedMsgStr::Singular(Cow::Borrowed("one"))
745        );
746
747        let mut singular = BorrowedMsgStr::None;
748        singular.set_slot(0, Cow::Borrowed("one"));
749        singular.set_slot(0, Cow::Borrowed("uno"));
750        singular.append_slot(0, Cow::Borrowed(" plus"));
751        assert_eq!(
752            singular,
753            BorrowedMsgStr::Singular(Cow::Borrowed("uno plus"))
754        );
755
756        let mut append_empty_plural = BorrowedMsgStr::Plural(Vec::new());
757        append_empty_plural.append_slot(0, Cow::Borrowed("zero"));
758        assert_eq!(
759            append_empty_plural,
760            BorrowedMsgStr::Plural(vec![Cow::Borrowed("zero")])
761        );
762
763        let mut empty_plural = BorrowedMsgStr::Plural(Vec::new());
764        empty_plural.set_slot(0, Cow::Borrowed("zero"));
765        empty_plural.append_slot(0, Cow::Borrowed(" plus"));
766        assert_eq!(
767            empty_plural,
768            BorrowedMsgStr::Plural(vec![Cow::Borrowed("zero plus")])
769        );
770
771        let mut msgstr = BorrowedMsgStr::None;
772
773        msgstr.set_slot(1, Cow::Borrowed("two"));
774        assert_eq!(
775            msgstr,
776            BorrowedMsgStr::Plural(vec![Cow::Borrowed(""), Cow::Borrowed("two")])
777        );
778
779        msgstr.append_slot(1, Cow::Borrowed(" plus"));
780        msgstr.append_slot(0, Cow::Borrowed("one"));
781        assert_eq!(
782            msgstr,
783            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one"), Cow::Borrowed("two plus")])
784        );
785
786        let mut defaulted = BorrowedMsgStr::None;
787        defaulted.ensure_singular();
788        defaulted.expand_singular_to_plural_width(3);
789        assert_eq!(
790            defaulted,
791            BorrowedMsgStr::Plural(vec![
792                Cow::Borrowed(""),
793                Cow::Borrowed(""),
794                Cow::Borrowed(""),
795            ])
796        );
797
798        let mut existing_plural = BorrowedMsgStr::Plural(vec![Cow::Borrowed("one")]);
799        existing_plural.expand_singular_to_plural_width(2);
800        assert_eq!(
801            existing_plural,
802            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one"), Cow::Borrowed("")])
803        );
804    }
805
806    #[test]
807    fn borrows_simple_fields() {
808        let input = r#"
809# translator
810msgid "hello"
811msgstr "world"
812"#;
813
814        let file = parse_po_borrowed(input).expect("borrowed parse");
815        assert_eq!(file.items[0].comments[0], Cow::Borrowed("translator"));
816        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
817        assert_eq!(
818            file.items[0].msgstr,
819            super::BorrowedMsgStr::Singular(Cow::Borrowed("world"))
820        );
821    }
822
823    #[test]
824    fn owns_unescaped_sequences_only_when_needed() {
825        let input = "msgid \"a\\n\"\nmsgstr \"b\\t\"\n";
826        let file = parse_po_borrowed(input).expect("borrowed parse with escapes");
827        assert_eq!(file.items[0].msgid, Cow::<str>::Owned("a\n".to_owned()));
828        assert_eq!(
829            file.items[0].msgstr,
830            super::BorrowedMsgStr::Singular(Cow::<str>::Owned("b\t".to_owned()))
831        );
832    }
833
834    #[test]
835    fn converts_borrowed_parse_to_owned() {
836        let input = "msgid \"hello\"\nmsgstr \"world\"\n";
837        let owned = parse_po_borrowed(input)
838            .expect("borrowed parse")
839            .into_owned();
840        assert_eq!(owned.items[0].msgid, "hello");
841        assert_eq!(owned.items[0].msgstr[0], "world");
842    }
843
844    #[test]
845    fn borrows_header_key_values_without_escapes() {
846        let input = concat!(
847            "msgid \"\"\n",
848            "msgstr \"\"\n",
849            "\"Language: de\\n\"\n",
850            "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
851        );
852        let file = parse_po_borrowed(input).expect("borrowed parse with headers");
853        assert_eq!(file.headers[0].key, Cow::Borrowed("Language"));
854        assert_eq!(file.headers[0].value, Cow::Borrowed("de"));
855    }
856
857    #[test]
858    fn strips_utf8_bom_prefix() {
859        let input = "\u{feff}msgid \"foo\"\nmsgstr \"bar\"\n";
860        let file = parse_po_borrowed(input).expect("borrowed parse");
861
862        assert_eq!(file.items.len(), 1);
863        assert_eq!(file.items[0].msgid, Cow::Borrowed("foo"));
864        assert_eq!(
865            file.items[0].msgstr,
866            super::BorrowedMsgStr::Singular(Cow::Borrowed("bar"))
867        );
868    }
869
870    #[test]
871    fn accepts_crlf_input_for_borrowed_parsing() {
872        let file = parse_po_borrowed("msgid \"foo\"\r\nmsgstr \"bar\"\r\n")
873            .expect("borrowed parse with crlf");
874
875        assert_eq!(file.items[0].msgid, Cow::Borrowed("foo"));
876        assert_eq!(
877            file.items[0].msgstr,
878            super::BorrowedMsgStr::Singular(Cow::Borrowed("bar"))
879        );
880    }
881
882    #[test]
883    fn parse_errors_include_line_position() {
884        let error = parse_po_borrowed("msgid \"ok\"\nmsgstr \"bad\"quote\"\n")
885            .expect_err("unescaped quote should fail");
886        let position = error.position().expect("position metadata");
887
888        assert_eq!(error.message(), "unescaped quote in string literal");
889        assert_eq!(position.offset(), 11);
890        assert_eq!(position.line(), 2);
891        assert_eq!(position.column(), 1);
892    }
893
894    #[test]
895    fn rejects_unrecognized_lines() {
896        let error = parse_po_borrowed("msgid \"ok\"\nmsgstr_ \"typo\"\n")
897            .expect_err("unknown PO line should fail");
898        let position = error.position().expect("position metadata");
899
900        assert_eq!(error.message(), "unrecognized PO syntax");
901        assert_eq!(position.line(), 2);
902        assert_eq!(position.column(), 1);
903    }
904
905    #[test]
906    fn parse_errors_include_line_position_for_plural_and_context_keywords() {
907        let plural_error =
908            parse_po_borrowed("msgid \"file\"\nmsgid_plural \"bad\"quote\"\nmsgstr[0] \"\"\n")
909                .expect_err("unescaped plural quote should fail");
910        let plural_position = plural_error.position().expect("plural position metadata");
911        assert_eq!(plural_error.message(), "unescaped quote in string literal");
912        assert_eq!(plural_position.line(), 2);
913        assert_eq!(plural_position.column(), 1);
914
915        let context_error = parse_po_borrowed("msgctxt \"bad\"quote\"\nmsgid \"x\"\nmsgstr \"\"\n")
916            .expect_err("unescaped context quote should fail");
917        let context_position = context_error.position().expect("context position metadata");
918        assert_eq!(context_error.message(), "unescaped quote in string literal");
919        assert_eq!(context_position.line(), 1);
920        assert_eq!(context_position.column(), 1);
921    }
922
923    #[test]
924    fn parses_owned_header_fragments_in_keyword_and_continuation_lines() {
925        let input = concat!(
926            "msgid \"\"\n",
927            "msgstr \"Project-Id-Version: ferrocat\\t1\\n\"\n",
928            "\"Language: de\\t\\n\"\n",
929        );
930        let file = parse_po_borrowed(input).expect("borrowed parse with owned headers");
931
932        assert_eq!(file.headers.len(), 2);
933        assert_eq!(
934            file.headers[0].key,
935            Cow::<str>::Owned("Project-Id-Version".to_owned())
936        );
937        assert_eq!(
938            file.headers[0].value,
939            Cow::<str>::Owned("ferrocat\t1".to_owned())
940        );
941        assert_eq!(
942            file.headers[1].key,
943            Cow::<str>::Owned("Language".to_owned())
944        );
945        assert_eq!(file.headers[1].value, Cow::<str>::Owned("de".to_owned()));
946    }
947
948    #[test]
949    fn parses_plural_metadata_flags_and_obsolete_items() {
950        let input = concat!(
951            "# translator\n",
952            "#. extracted\n",
953            "#: src/app.rs:1 src/lib.rs:2\n",
954            "#, fuzzy, c-format\n",
955            "#@ domain: admin\n",
956            "msgctxt \"menu\"\n",
957            "msgid \"file\"\n",
958            "msgid_plural \"files\"\n",
959            "msgstr[0] \"Datei\"\n",
960            "msgstr[1] \"Dateien\"\n",
961            "\n",
962            "#~ msgid \"old\"\n",
963            "#~ msgstr \"alt\"\n",
964        );
965
966        let file = parse_po_borrowed(input).expect("borrowed plural parse");
967        assert_eq!(file.items.len(), 2);
968
969        let item = &file.items[0];
970        assert_eq!(item.msgctxt.as_deref(), Some("menu"));
971        assert_eq!(item.msgid_plural.as_deref(), Some("files"));
972        assert_eq!(
973            item.msgstr,
974            BorrowedMsgStr::Plural(vec![Cow::Borrowed("Datei"), Cow::Borrowed("Dateien"),])
975        );
976        assert_eq!(
977            item.references.as_slice(),
978            vec![Cow::Borrowed("src/app.rs:1"), Cow::Borrowed("src/lib.rs:2")].as_slice()
979        );
980        assert_eq!(
981            item.flags.as_slice(),
982            vec![Cow::Borrowed("fuzzy"), Cow::Borrowed("c-format")].as_slice()
983        );
984        assert_eq!(
985            item.metadata.as_slice(),
986            vec![(Cow::Borrowed("domain"), Cow::Borrowed("admin"))].as_slice()
987        );
988
989        assert!(file.items[1].obsolete);
990        assert_eq!(file.items[1].msgid, Cow::Borrowed("old"));
991    }
992
993    #[test]
994    fn parses_owned_headers_and_multiline_fields_when_escapes_are_present() {
995        let input = concat!(
996            "msgid \"\"\n",
997            "msgstr \"\"\n",
998            "\"Project-Id-Version: Demo \\\"Suite\\\"\\n\"\n",
999            "\"Plural-Forms: nplurals=3; plural=(n > 1);\\n\"\n",
1000            "\n",
1001            "msgctxt \"cta\"\n",
1002            "msgid \"hel\"\n",
1003            "\"lo\"\n",
1004            "msgstr \"wor\"\n",
1005            "\"ld\"\n",
1006        );
1007
1008        let file = parse_po_borrowed(input).expect("borrowed parse with owned headers");
1009        assert_eq!(
1010            file.headers[0],
1011            super::BorrowedHeader {
1012                key: Cow::Owned("Project-Id-Version".to_owned()),
1013                value: Cow::Owned("Demo \"Suite\"".to_owned()),
1014            }
1015        );
1016        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
1017        assert_eq!(file.items[0].msgctxt.as_deref(), Some("cta"));
1018        assert_eq!(
1019            file.items[0].msgstr,
1020            BorrowedMsgStr::Singular(Cow::Borrowed("world"))
1021        );
1022        assert_eq!(file.items[0].nplurals, 3);
1023    }
1024
1025    #[test]
1026    fn parses_sparse_plural_slots_and_multiline_context_plural_and_msgstr() {
1027        let input = concat!(
1028            "msgid \"\"\n",
1029            "msgstr \"\"\n",
1030            "\"Plural-Forms: nplurals=3; plural=(n > 1);\\n\"\n",
1031            "\n",
1032            "msgctxt \"ct\"\n",
1033            "\"a\"\n",
1034            "msgid \"item\"\n",
1035            "msgid_plural \"items\"\n",
1036            "\" total\"\n",
1037            "msgstr[1] \"two\"\n",
1038            "\" plus\"\n",
1039            "msgstr[0] \"one\"\n",
1040            "\" plus\"\n",
1041            "\n",
1042            "msgid \"missing translation\"\n",
1043        );
1044
1045        let file = parse_po_borrowed(input).expect("parse sparse plural");
1046
1047        assert_eq!(file.items.len(), 2);
1048        let plural = &file.items[0];
1049        assert_eq!(plural.msgctxt.as_deref(), Some("cta"));
1050        assert_eq!(plural.msgid_plural.as_deref(), Some("items total"));
1051        assert_eq!(
1052            plural.msgstr,
1053            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one plus"), Cow::Borrowed("two plus"),])
1054        );
1055        assert_eq!(file.items[1].msgid, Cow::Borrowed("missing translation"));
1056        assert_eq!(
1057            file.items[1].msgstr,
1058            BorrowedMsgStr::Singular(Cow::Borrowed(""))
1059        );
1060    }
1061}