Skip to main content

ferrocat_po/
borrowed.rs

1use std::borrow::Cow;
2
3use crate::scan::{
4    CommentKind, Keyword, LineKind, LineScanner, classify_line, find_quoted_bounds, has_byte,
5    parse_plural_index, split_once_byte, trim_ascii,
6};
7use crate::text::{extract_quoted_bytes_cow, split_reference_comment};
8use crate::utf8::input_slice_as_str;
9use crate::{Header, MsgStr, ParseError, PoFile, PoItem};
10
11/// Borrowed PO document that reuses slices from the original input whenever
12/// possible.
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct BorrowedPoFile<'a> {
15    /// File-level translator comments that appear before the header block.
16    pub comments: Vec<Cow<'a, str>>,
17    /// File-level extracted comments that appear before the header block.
18    pub extracted_comments: Vec<Cow<'a, str>>,
19    /// Parsed header entries from the leading empty `msgid` block.
20    pub headers: Vec<BorrowedHeader<'a>>,
21    /// Regular catalog items in source order.
22    pub items: Vec<BorrowedPoItem<'a>>,
23}
24
25impl BorrowedPoFile<'_> {
26    /// Converts the borrowed document into the owned [`PoFile`] representation.
27    #[must_use]
28    pub fn into_owned(self) -> PoFile {
29        PoFile {
30            comments: self.comments.into_iter().map(Cow::into_owned).collect(),
31            extracted_comments: self
32                .extracted_comments
33                .into_iter()
34                .map(Cow::into_owned)
35                .collect(),
36            headers: self
37                .headers
38                .into_iter()
39                .map(BorrowedHeader::into_owned)
40                .collect(),
41            items: self
42                .items
43                .into_iter()
44                .map(BorrowedPoItem::into_owned)
45                .collect(),
46        }
47    }
48}
49
50/// Borrowed header entry from the PO header block.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct BorrowedHeader<'a> {
53    /// Header name such as `Language` or `Plural-Forms`.
54    pub key: Cow<'a, str>,
55    /// Header value without the trailing newline.
56    pub value: Cow<'a, str>,
57}
58
59impl BorrowedHeader<'_> {
60    /// Converts the borrowed header into an owned [`Header`].
61    #[must_use]
62    pub fn into_owned(self) -> Header {
63        Header {
64            key: self.key.into_owned(),
65            value: self.value.into_owned(),
66        }
67    }
68}
69
70/// Borrowed gettext message entry.
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct BorrowedPoItem<'a> {
73    /// Source message identifier.
74    pub msgid: Cow<'a, str>,
75    /// Optional gettext message context.
76    pub msgctxt: Option<Cow<'a, str>>,
77    /// Source references such as `src/app.rs:10`.
78    pub references: Vec<Cow<'a, str>>,
79    /// Optional plural source identifier.
80    pub msgid_plural: Option<Cow<'a, str>>,
81    /// Translation payload for the message.
82    pub msgstr: BorrowedMsgStr<'a>,
83    /// Translator comments attached to the item.
84    pub comments: Vec<Cow<'a, str>>,
85    /// Extracted comments attached to the item.
86    pub extracted_comments: Vec<Cow<'a, str>>,
87    /// Flags such as `fuzzy`.
88    pub flags: Vec<Cow<'a, str>>,
89    /// Raw metadata lines that do not fit the dedicated fields.
90    pub metadata: Vec<(Cow<'a, str>, Cow<'a, str>)>,
91    /// Whether the item is marked obsolete.
92    pub obsolete: bool,
93    /// Number of plural slots expected when the item is serialized.
94    pub nplurals: usize,
95}
96
97impl BorrowedPoItem<'_> {
98    fn new(nplurals: usize) -> Self {
99        Self {
100            nplurals,
101            ..Self::default()
102        }
103    }
104
105    /// Converts the borrowed item into an owned [`PoItem`].
106    #[must_use]
107    pub fn into_owned(self) -> PoItem {
108        PoItem {
109            msgid: self.msgid.into_owned(),
110            msgctxt: self.msgctxt.map(Cow::into_owned),
111            references: self.references.into_iter().map(Cow::into_owned).collect(),
112            msgid_plural: self.msgid_plural.map(Cow::into_owned),
113            msgstr: self.msgstr.into_owned(),
114            comments: self.comments.into_iter().map(Cow::into_owned).collect(),
115            extracted_comments: self
116                .extracted_comments
117                .into_iter()
118                .map(Cow::into_owned)
119                .collect(),
120            flags: self.flags.into_iter().map(Cow::into_owned).collect(),
121            metadata: self
122                .metadata
123                .into_iter()
124                .map(|(key, value)| (key.into_owned(), value.into_owned()))
125                .collect(),
126            obsolete: self.obsolete,
127            nplurals: self.nplurals,
128        }
129    }
130}
131
132/// Borrowed translation payload for a PO item.
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub enum BorrowedMsgStr<'a> {
135    /// No translation values are present.
136    #[default]
137    None,
138    /// Single translation string.
139    Singular(Cow<'a, str>),
140    /// Plural translation strings indexed by plural slot.
141    Plural(Vec<Cow<'a, str>>),
142}
143
144impl BorrowedMsgStr<'_> {
145    const fn is_empty(&self) -> bool {
146        matches!(self, Self::None)
147    }
148
149    fn len(&self) -> usize {
150        match self {
151            Self::None => 0,
152            Self::Singular(_) => 1,
153            Self::Plural(values) => values.len(),
154        }
155    }
156
157    /// Converts the borrowed payload into an owned [`MsgStr`].
158    #[must_use]
159    pub fn into_owned(self) -> MsgStr {
160        match self {
161            Self::None => MsgStr::None,
162            Self::Singular(value) => MsgStr::Singular(value.into_owned()),
163            Self::Plural(values) => {
164                MsgStr::Plural(values.into_iter().map(Cow::into_owned).collect())
165            }
166        }
167    }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Context {
172    Id,
173    IdPlural,
174    Str,
175    Ctxt,
176}
177
178#[derive(Debug)]
179struct ParserState<'a> {
180    item: BorrowedPoItem<'a>,
181    header_entries: Vec<BorrowedHeader<'a>>,
182    msgstr: BorrowedMsgStr<'a>,
183    context: Option<Context>,
184    plural_index: usize,
185    obsolete_line_count: usize,
186    content_line_count: usize,
187    has_keyword: bool,
188}
189
190impl<'a> ParserState<'a> {
191    fn new(nplurals: usize) -> Self {
192        Self {
193            item: BorrowedPoItem::new(nplurals),
194            header_entries: Vec::new(),
195            msgstr: BorrowedMsgStr::None,
196            context: None,
197            plural_index: 0,
198            obsolete_line_count: 0,
199            content_line_count: 0,
200            has_keyword: false,
201        }
202    }
203
204    fn reset(&mut self, nplurals: usize) {
205        *self = Self::new(nplurals);
206    }
207
208    fn set_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
209        match (&mut self.msgstr, plural_index) {
210            (BorrowedMsgStr::None, 0) => self.msgstr = BorrowedMsgStr::Singular(value),
211            (BorrowedMsgStr::Singular(existing), 0) => *existing = value,
212            (BorrowedMsgStr::Plural(values), 0) => {
213                if values.is_empty() {
214                    values.push(Cow::Borrowed(""));
215                }
216                values[0] = value;
217            }
218            _ => {
219                let msgstr = self.promote_plural_msgstr(plural_index);
220                msgstr[plural_index] = value;
221            }
222        }
223    }
224
225    fn append_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
226        match (&mut self.msgstr, plural_index) {
227            (BorrowedMsgStr::None, 0) => self.msgstr = BorrowedMsgStr::Singular(value),
228            (BorrowedMsgStr::Singular(existing), 0) => existing.to_mut().push_str(value.as_ref()),
229            (BorrowedMsgStr::Plural(values), 0) => {
230                if values.is_empty() {
231                    values.push(Cow::Borrowed(""));
232                }
233                values[0].to_mut().push_str(value.as_ref());
234            }
235            _ => {
236                let msgstr = self.promote_plural_msgstr(plural_index);
237                msgstr[plural_index].to_mut().push_str(value.as_ref());
238            }
239        }
240    }
241
242    fn materialize_msgstr(&mut self) {
243        debug_assert!(self.item.msgstr.is_empty());
244        self.item.msgstr = std::mem::take(&mut self.msgstr);
245    }
246
247    fn promote_plural_msgstr(&mut self, plural_index: usize) -> &mut Vec<Cow<'a, str>> {
248        if !matches!(self.msgstr, BorrowedMsgStr::Plural(_)) {
249            self.msgstr = match std::mem::take(&mut self.msgstr) {
250                BorrowedMsgStr::None => BorrowedMsgStr::Plural(Vec::with_capacity(2)),
251                BorrowedMsgStr::Singular(value) => {
252                    let mut values = Vec::with_capacity(2);
253                    values.push(value);
254                    BorrowedMsgStr::Plural(values)
255                }
256                BorrowedMsgStr::Plural(values) => BorrowedMsgStr::Plural(values),
257            };
258        }
259        let BorrowedMsgStr::Plural(values) = &mut self.msgstr else {
260            unreachable!("plural msgstr promotion must yield plural storage");
261        };
262        if values.len() <= plural_index {
263            values.resize(plural_index + 1, Cow::Borrowed(""));
264        }
265        values
266    }
267}
268
269#[derive(Debug, Clone, Copy)]
270struct BorrowedLine<'a> {
271    trimmed: &'a [u8],
272    obsolete: bool,
273}
274
275/// Parses PO content into a borrowed representation.
276///
277/// This parser keeps references into `input` for fields that do not need
278/// unescaping, which reduces allocations compared with [`crate::parse_po`].
279///
280/// # Errors
281///
282/// Returns [`ParseError`] when the input is not valid PO syntax.
283pub fn parse_po_borrowed(input: &str) -> Result<BorrowedPoFile<'_>, ParseError> {
284    let input = input.strip_prefix('\u{feff}').unwrap_or(input);
285    if input.as_bytes().contains(&b'\r') {
286        return Err(ParseError::new(
287            "borrowed PO parsing currently requires LF-only input",
288        ));
289    }
290
291    let mut file = BorrowedPoFile::default();
292    file.items.reserve((input.len() / 96).max(1));
293    let mut current_nplurals = 2;
294    let mut state = ParserState::new(current_nplurals);
295
296    for line in LineScanner::new(input.as_bytes()) {
297        parse_line(
298            BorrowedLine {
299                trimmed: line.trimmed,
300                obsolete: line.obsolete,
301            },
302            &mut state,
303            &mut file,
304            &mut current_nplurals,
305        )?;
306    }
307
308    finish_item(&mut state, &mut file, &mut current_nplurals);
309
310    Ok(file)
311}
312
313fn parse_line<'a>(
314    line: BorrowedLine<'a>,
315    state: &mut ParserState<'a>,
316    file: &mut BorrowedPoFile<'a>,
317    current_nplurals: &mut usize,
318) -> Result<(), ParseError> {
319    match classify_line(line.trimmed) {
320        LineKind::Continuation => {
321            append_continuation(line.trimmed, line.obsolete, state)?;
322            Ok(())
323        }
324        LineKind::Comment(kind) => {
325            parse_comment_line(line.trimmed, kind, state, file, current_nplurals);
326            Ok(())
327        }
328        LineKind::Keyword(keyword) => parse_keyword_line(
329            line.trimmed,
330            line.obsolete,
331            keyword,
332            state,
333            file,
334            current_nplurals,
335        ),
336        LineKind::Other => Ok(()),
337    }
338}
339
340fn parse_comment_line<'a>(
341    line_bytes: &'a [u8],
342    kind: CommentKind,
343    state: &mut ParserState<'a>,
344    file: &mut BorrowedPoFile<'a>,
345    current_nplurals: &mut usize,
346) {
347    finish_item(state, file, current_nplurals);
348
349    match kind {
350        CommentKind::Reference => {
351            let reference_line = trimmed_str(&line_bytes[2..]);
352            state
353                .item
354                .references
355                .extend(split_reference_comment(reference_line));
356        }
357        CommentKind::Flags => {
358            for flag in trimmed_str(&line_bytes[2..]).split(',') {
359                state.item.flags.push(Cow::Borrowed(flag.trim()));
360            }
361        }
362        CommentKind::Extracted => state
363            .item
364            .extracted_comments
365            .push(trimmed_cow(&line_bytes[2..])),
366        CommentKind::Metadata => {
367            let trimmed = trim_ascii(&line_bytes[2..]);
368            if let Some((key_bytes, value_bytes)) = split_once_byte(trimmed, b':') {
369                let key = trimmed_cow(key_bytes);
370                if !key.is_empty() {
371                    let value = trimmed_cow(value_bytes);
372                    state.item.metadata.push((key, value));
373                }
374            }
375        }
376        CommentKind::Translator => state.item.comments.push(trimmed_cow(&line_bytes[1..])),
377        CommentKind::Other => {}
378    }
379}
380
381fn parse_keyword_line<'a>(
382    line_bytes: &'a [u8],
383    obsolete: bool,
384    keyword: Keyword,
385    state: &mut ParserState<'a>,
386    file: &mut BorrowedPoFile<'a>,
387    current_nplurals: &mut usize,
388) -> Result<(), ParseError> {
389    match keyword {
390        Keyword::IdPlural => {
391            state.obsolete_line_count += usize::from(obsolete);
392            state.item.msgid_plural = Some(extract_quoted_bytes_cow(line_bytes)?);
393            state.context = Some(Context::IdPlural);
394            state.content_line_count += 1;
395            state.has_keyword = true;
396        }
397        Keyword::Id => {
398            finish_item(state, file, current_nplurals);
399            state.obsolete_line_count += usize::from(obsolete);
400            state.item.msgid = extract_quoted_bytes_cow(line_bytes)?;
401            state.context = Some(Context::Id);
402            state.content_line_count += 1;
403            state.has_keyword = true;
404        }
405        Keyword::Str => {
406            let plural_index = parse_plural_index(line_bytes).unwrap_or(0);
407            state.plural_index = plural_index;
408            state.obsolete_line_count += usize::from(obsolete);
409            state.set_msgstr(plural_index, extract_quoted_bytes_cow(line_bytes)?);
410            if is_header_candidate(state) {
411                state
412                    .header_entries
413                    .extend(parse_header_fragment(line_bytes)?);
414            }
415            state.context = Some(Context::Str);
416            state.content_line_count += 1;
417            state.has_keyword = true;
418        }
419        Keyword::Ctxt => {
420            finish_item(state, file, current_nplurals);
421            state.obsolete_line_count += usize::from(obsolete);
422            state.item.msgctxt = Some(extract_quoted_bytes_cow(line_bytes)?);
423            state.context = Some(Context::Ctxt);
424            state.content_line_count += 1;
425            state.has_keyword = true;
426        }
427    }
428
429    Ok(())
430}
431
432fn append_continuation<'a>(
433    line_bytes: &'a [u8],
434    obsolete: bool,
435    state: &mut ParserState<'a>,
436) -> Result<(), ParseError> {
437    state.obsolete_line_count += usize::from(obsolete);
438    state.content_line_count += 1;
439    let value = extract_quoted_bytes_cow(line_bytes)?;
440
441    match state.context {
442        Some(Context::Str) => {
443            state.append_msgstr(state.plural_index, value);
444            if is_header_candidate(state) {
445                state
446                    .header_entries
447                    .extend(parse_header_fragment(line_bytes)?);
448            }
449        }
450        Some(Context::Id) => state.item.msgid.to_mut().push_str(value.as_ref()),
451        Some(Context::IdPlural) => {
452            let target = state.item.msgid_plural.get_or_insert(Cow::Borrowed(""));
453            target.to_mut().push_str(value.as_ref());
454        }
455        Some(Context::Ctxt) => {
456            let target = state.item.msgctxt.get_or_insert(Cow::Borrowed(""));
457            target.to_mut().push_str(value.as_ref());
458        }
459        None => {}
460    }
461
462    Ok(())
463}
464
465fn finish_item<'a>(
466    state: &mut ParserState<'a>,
467    file: &mut BorrowedPoFile<'a>,
468    current_nplurals: &mut usize,
469) {
470    if !state.has_keyword {
471        return;
472    }
473
474    if state.item.msgid.is_empty() && !is_header_state(state) {
475        return;
476    }
477
478    if state.obsolete_line_count >= state.content_line_count && state.content_line_count > 0 {
479        state.item.obsolete = true;
480    }
481
482    if is_header_state(state) && file.headers.is_empty() && file.items.is_empty() {
483        file.comments = std::mem::take(&mut state.item.comments);
484        file.extracted_comments = std::mem::take(&mut state.item.extracted_comments);
485        file.headers = std::mem::take(&mut state.header_entries);
486        *current_nplurals = parse_nplurals(&file.headers).unwrap_or(2);
487        state.reset(*current_nplurals);
488        return;
489    }
490
491    state.materialize_msgstr();
492
493    if state.item.msgstr.is_empty() {
494        state.item.msgstr = BorrowedMsgStr::Singular(Cow::Borrowed(""));
495    }
496    if state.item.msgid_plural.is_some() && state.item.msgstr.len() == 1 {
497        let mut values = match std::mem::take(&mut state.item.msgstr) {
498            BorrowedMsgStr::None => Vec::new(),
499            BorrowedMsgStr::Singular(value) => vec![value],
500            BorrowedMsgStr::Plural(values) => values,
501        };
502        values.resize(state.item.nplurals.max(1), Cow::Borrowed(""));
503        state.item.msgstr = BorrowedMsgStr::Plural(values);
504    }
505
506    state.item.nplurals = *current_nplurals;
507    file.items.push(std::mem::take(&mut state.item));
508    state.reset(*current_nplurals);
509}
510
511fn is_header_state(state: &ParserState<'_>) -> bool {
512    state.item.msgid.is_empty()
513        && state.item.msgctxt.is_none()
514        && state.item.msgid_plural.is_none()
515        && !state.msgstr.is_empty()
516}
517
518fn is_header_candidate(state: &ParserState<'_>) -> bool {
519    state.item.msgid.is_empty()
520        && state.item.msgctxt.is_none()
521        && state.item.msgid_plural.is_none()
522        && state.plural_index == 0
523}
524
525fn parse_header_fragment(line_bytes: &[u8]) -> Result<Vec<BorrowedHeader<'_>>, ParseError> {
526    let Some((start, end)) = find_quoted_bounds(line_bytes) else {
527        return Ok(Vec::new());
528    };
529    let raw = &line_bytes[start..end];
530
531    if header_fragment_is_borrowable(raw) {
532        return Ok(parse_header_fragment_borrowed(raw));
533    }
534
535    parse_header_fragment_owned(line_bytes)
536}
537
538fn parse_header_fragment_borrowed(raw: &[u8]) -> Vec<BorrowedHeader<'_>> {
539    let mut headers = Vec::new();
540    let mut start = 0usize;
541    let mut index = 0usize;
542
543    while index < raw.len() {
544        if raw[index] == b'\\' && raw.get(index + 1) == Some(&b'n') {
545            push_borrowed_header_segment(&raw[start..index], &mut headers);
546            index += 2;
547            start = index;
548            continue;
549        }
550        index += 1;
551    }
552
553    push_borrowed_header_segment(&raw[start..], &mut headers);
554    headers
555}
556
557fn push_borrowed_header_segment<'a>(segment: &'a [u8], out: &mut Vec<BorrowedHeader<'a>>) {
558    if segment.is_empty() {
559        return;
560    }
561    if let Some((key_bytes, value_bytes)) = split_once_byte(segment, b':') {
562        out.push(BorrowedHeader {
563            key: trimmed_cow(key_bytes),
564            value: trimmed_cow(value_bytes),
565        });
566    }
567}
568
569fn parse_header_fragment_owned(line_bytes: &[u8]) -> Result<Vec<BorrowedHeader<'_>>, ParseError> {
570    let decoded = extract_quoted_bytes_cow(line_bytes)?;
571    let mut headers = Vec::new();
572    for segment in decoded.split('\n') {
573        if segment.is_empty() {
574            continue;
575        }
576        if let Some((key, value)) = segment.split_once(':') {
577            headers.push(BorrowedHeader {
578                key: Cow::Owned(key.trim().to_owned()),
579                value: Cow::Owned(value.trim().to_owned()),
580            });
581        }
582    }
583    Ok(headers)
584}
585
586fn header_fragment_is_borrowable(raw: &[u8]) -> bool {
587    let mut index = 0usize;
588    while index < raw.len() {
589        if raw[index] == b'\\' {
590            if raw.get(index + 1) != Some(&b'n') {
591                return false;
592            }
593            index += 2;
594            continue;
595        }
596        index += 1;
597    }
598    !has_byte(b'"', raw)
599}
600
601fn parse_nplurals(headers: &[BorrowedHeader<'_>]) -> Option<usize> {
602    let plural_forms = headers
603        .iter()
604        .find(|header| header.key.as_ref() == "Plural-Forms")?
605        .value
606        .as_bytes();
607    let mut rest = plural_forms;
608
609    while !rest.is_empty() {
610        let (part, next) = match split_once_byte(rest, b';') {
611            Some((part, tail)) => (part, tail),
612            None => (rest, &b""[..]),
613        };
614        let trimmed = trim_ascii(part);
615        if let Some((key, value)) = split_once_byte(trimmed, b'=')
616            && trim_ascii(key) == b"nplurals"
617            && let value = bytes_to_str(trim_ascii(value))
618            && let Ok(parsed) = value.parse::<usize>()
619        {
620            return Some(parsed);
621        }
622        rest = next;
623    }
624
625    None
626}
627
628fn bytes_to_str(bytes: &[u8]) -> &str {
629    input_slice_as_str(bytes)
630}
631
632fn trimmed_str(bytes: &[u8]) -> &str {
633    bytes_to_str(trim_ascii(bytes))
634}
635
636fn trimmed_cow(bytes: &[u8]) -> Cow<'_, str> {
637    Cow::Borrowed(trimmed_str(bytes))
638}
639
640#[cfg(test)]
641mod tests {
642    use std::borrow::Cow;
643
644    use super::{BorrowedMsgStr, parse_po_borrowed};
645
646    #[test]
647    fn borrows_simple_fields() {
648        let input = r#"
649# translator
650msgid "hello"
651msgstr "world"
652"#;
653
654        let file = parse_po_borrowed(input).expect("borrowed parse");
655        assert_eq!(file.items[0].comments[0], Cow::Borrowed("translator"));
656        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
657        assert_eq!(
658            file.items[0].msgstr,
659            super::BorrowedMsgStr::Singular(Cow::Borrowed("world"))
660        );
661    }
662
663    #[test]
664    fn owns_unescaped_sequences_only_when_needed() {
665        let input = "msgid \"a\\n\"\nmsgstr \"b\\t\"\n";
666        let file = parse_po_borrowed(input).expect("borrowed parse with escapes");
667        assert_eq!(file.items[0].msgid, Cow::<str>::Owned("a\n".to_owned()));
668        assert_eq!(
669            file.items[0].msgstr,
670            super::BorrowedMsgStr::Singular(Cow::<str>::Owned("b\t".to_owned()))
671        );
672    }
673
674    #[test]
675    fn converts_borrowed_parse_to_owned() {
676        let input = "msgid \"hello\"\nmsgstr \"world\"\n";
677        let owned = parse_po_borrowed(input)
678            .expect("borrowed parse")
679            .into_owned();
680        assert_eq!(owned.items[0].msgid, "hello");
681        assert_eq!(owned.items[0].msgstr[0], "world");
682    }
683
684    #[test]
685    fn borrows_header_key_values_without_escapes() {
686        let input = concat!(
687            "msgid \"\"\n",
688            "msgstr \"\"\n",
689            "\"Language: de\\n\"\n",
690            "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
691        );
692        let file = parse_po_borrowed(input).expect("borrowed parse with headers");
693        assert_eq!(file.headers[0].key, Cow::Borrowed("Language"));
694        assert_eq!(file.headers[0].value, Cow::Borrowed("de"));
695    }
696
697    #[test]
698    fn strips_utf8_bom_prefix() {
699        let input = "\u{feff}msgid \"foo\"\nmsgstr \"bar\"\n";
700        let file = parse_po_borrowed(input).expect("borrowed parse");
701
702        assert_eq!(file.items.len(), 1);
703        assert_eq!(file.items[0].msgid, Cow::Borrowed("foo"));
704        assert_eq!(
705            file.items[0].msgstr,
706            super::BorrowedMsgStr::Singular(Cow::Borrowed("bar"))
707        );
708    }
709
710    #[test]
711    fn rejects_crlf_input_for_borrowed_parsing() {
712        let error = parse_po_borrowed("msgid \"foo\"\r\nmsgstr \"bar\"\r\n")
713            .expect_err("crlf should be rejected");
714        assert!(error.to_string().contains("LF-only"));
715    }
716
717    #[test]
718    fn parses_plural_metadata_flags_and_obsolete_items() {
719        let input = concat!(
720            "# translator\n",
721            "#. extracted\n",
722            "#: src/app.rs:1 src/lib.rs:2\n",
723            "#, fuzzy, c-format\n",
724            "#@ domain: admin\n",
725            "msgctxt \"menu\"\n",
726            "msgid \"file\"\n",
727            "msgid_plural \"files\"\n",
728            "msgstr[0] \"Datei\"\n",
729            "msgstr[1] \"Dateien\"\n",
730            "\n",
731            "#~ msgid \"old\"\n",
732            "#~ msgstr \"alt\"\n",
733        );
734
735        let file = parse_po_borrowed(input).expect("borrowed plural parse");
736        assert_eq!(file.items.len(), 2);
737
738        let item = &file.items[0];
739        assert_eq!(item.msgctxt.as_deref(), Some("menu"));
740        assert_eq!(item.msgid_plural.as_deref(), Some("files"));
741        assert_eq!(
742            item.msgstr,
743            BorrowedMsgStr::Plural(vec![Cow::Borrowed("Datei"), Cow::Borrowed("Dateien"),])
744        );
745        assert_eq!(
746            item.references,
747            vec![Cow::Borrowed("src/app.rs:1"), Cow::Borrowed("src/lib.rs:2")]
748        );
749        assert_eq!(
750            item.flags,
751            vec![Cow::Borrowed("fuzzy"), Cow::Borrowed("c-format")]
752        );
753        assert_eq!(
754            item.metadata,
755            vec![(Cow::Borrowed("domain"), Cow::Borrowed("admin"))]
756        );
757
758        assert!(file.items[1].obsolete);
759        assert_eq!(file.items[1].msgid, Cow::Borrowed("old"));
760    }
761
762    #[test]
763    fn parses_owned_headers_and_multiline_fields_when_escapes_are_present() {
764        let input = concat!(
765            "msgid \"\"\n",
766            "msgstr \"\"\n",
767            "\"Project-Id-Version: Demo \\\"Suite\\\"\\n\"\n",
768            "\"Plural-Forms: nplurals=3; plural=(n > 1);\\n\"\n",
769            "\n",
770            "msgctxt \"cta\"\n",
771            "msgid \"hel\"\n",
772            "\"lo\"\n",
773            "msgstr \"wor\"\n",
774            "\"ld\"\n",
775        );
776
777        let file = parse_po_borrowed(input).expect("borrowed parse with owned headers");
778        assert_eq!(
779            file.headers[0],
780            super::BorrowedHeader {
781                key: Cow::Owned("Project-Id-Version".to_owned()),
782                value: Cow::Owned("Demo \"Suite\"".to_owned()),
783            }
784        );
785        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
786        assert_eq!(file.items[0].msgctxt.as_deref(), Some("cta"));
787        assert_eq!(
788            file.items[0].msgstr,
789            BorrowedMsgStr::Singular(Cow::Borrowed("world"))
790        );
791        assert_eq!(file.items[0].nplurals, 3);
792    }
793}