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, 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    pub(crate) fn set_slot(&mut self, plural_index: usize, value: Cow<'a, str>) {
151        match (&mut *self, plural_index) {
152            (Self::None, 0) => *self = Self::Singular(value),
153            (Self::Singular(existing), 0) => *existing = value,
154            (Self::Plural(values), 0) => {
155                if values.is_empty() {
156                    values.push(Cow::Borrowed(""));
157                }
158                values[0] = value;
159            }
160            _ => {
161                let values = self.promote_plural(plural_index);
162                values[plural_index] = value;
163            }
164        }
165    }
166
167    pub(crate) fn append_slot(&mut self, plural_index: usize, value: Cow<'a, str>) {
168        match (&mut *self, plural_index) {
169            (Self::None, 0) => *self = Self::Singular(value),
170            (Self::Singular(existing), 0) => existing.to_mut().push_str(value.as_ref()),
171            (Self::Plural(values), 0) => {
172                if values.is_empty() {
173                    values.push(Cow::Borrowed(""));
174                }
175                values[0].to_mut().push_str(value.as_ref());
176            }
177            _ => {
178                let values = self.promote_plural(plural_index);
179                values[plural_index].to_mut().push_str(value.as_ref());
180            }
181        }
182    }
183
184    pub(crate) fn ensure_singular(&mut self) {
185        if self.is_empty() {
186            *self = Self::Singular(Cow::Borrowed(""));
187        }
188    }
189
190    pub(crate) fn expand_singular_to_plural_width(&mut self, width: usize) {
191        match self {
192            Self::Singular(value) => {
193                let mut values = vec![std::mem::take(value)];
194                values.resize(width.max(1), Cow::Borrowed(""));
195                *self = Self::Plural(values);
196            }
197            Self::Plural(values) if values.len() == 1 => {
198                values.resize(width.max(1), Cow::Borrowed(""));
199            }
200            Self::None | Self::Plural(_) => {}
201        }
202    }
203
204    fn promote_plural(&mut self, plural_index: usize) -> &mut Vec<Cow<'a, str>> {
205        match self {
206            Self::None => {
207                *self = Self::Plural(Vec::with_capacity(2));
208                self.promote_plural(plural_index)
209            }
210            Self::Singular(value) => {
211                let value = std::mem::take(value);
212                *self = Self::Plural(vec![value]);
213                self.promote_plural(plural_index)
214            }
215            Self::Plural(values) => {
216                if values.len() <= plural_index {
217                    values.resize(plural_index + 1, Cow::Borrowed(""));
218                }
219                values
220            }
221        }
222    }
223
224    /// Converts the borrowed payload into an owned [`MsgStr`].
225    #[must_use]
226    pub fn into_owned(self) -> MsgStr {
227        match self {
228            Self::None => MsgStr::None,
229            Self::Singular(value) => MsgStr::Singular(value.into_owned()),
230            Self::Plural(values) => {
231                MsgStr::Plural(values.into_iter().map(Cow::into_owned).collect())
232            }
233        }
234    }
235}
236
237#[derive(Debug)]
238struct ParserState<'a> {
239    item: BorrowedPoItem<'a>,
240    header_entries: Vec<BorrowedHeader<'a>>,
241    msgstr: BorrowedMsgStr<'a>,
242    line: PoLineState,
243}
244
245impl<'a> ParserState<'a> {
246    fn new(nplurals: usize) -> Self {
247        Self {
248            item: BorrowedPoItem::new(nplurals),
249            header_entries: Vec::new(),
250            msgstr: BorrowedMsgStr::None,
251            line: PoLineState::default(),
252        }
253    }
254
255    fn reset(&mut self, nplurals: usize) {
256        *self = Self::new(nplurals);
257    }
258
259    fn set_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
260        self.msgstr.set_slot(plural_index, value);
261    }
262
263    fn append_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
264        self.msgstr.append_slot(plural_index, value);
265    }
266
267    fn materialize_msgstr(&mut self) {
268        debug_assert!(self.item.msgstr.is_empty());
269        self.item.msgstr = std::mem::take(&mut self.msgstr);
270    }
271}
272
273#[derive(Debug, Clone, Copy)]
274struct BorrowedLine<'a> {
275    trimmed: &'a [u8],
276    obsolete: bool,
277    position: ParsePosition,
278}
279
280/// Parses PO content into a borrowed representation.
281///
282/// This parser keeps references into `input` for fields that do not need
283/// unescaping, which reduces allocations compared with [`crate::parse_po`].
284/// LF, CRLF, and bare CR line endings are accepted.
285///
286/// # Errors
287///
288/// Returns [`ParseError`] when the input is not valid PO syntax.
289pub fn parse_po_borrowed(input: &str) -> Result<BorrowedPoFile<'_>, ParseError> {
290    let input = input.strip_prefix('\u{feff}').unwrap_or(input);
291
292    let mut file = BorrowedPoFile::default();
293    file.items.reserve((input.len() / 96).max(1));
294    let mut current_nplurals = 2;
295    let mut state = ParserState::new(current_nplurals);
296
297    for line in LineScanner::new(input.as_bytes()) {
298        parse_line(
299            BorrowedLine {
300                trimmed: line.trimmed,
301                obsolete: line.obsolete,
302                position: line.position,
303            },
304            &mut state,
305            &mut file,
306            &mut current_nplurals,
307        )?;
308    }
309
310    finish_item(&mut state, &mut file, &mut current_nplurals);
311
312    Ok(file)
313}
314
315fn parse_line<'a>(
316    line: BorrowedLine<'a>,
317    state: &mut ParserState<'a>,
318    file: &mut BorrowedPoFile<'a>,
319    current_nplurals: &mut usize,
320) -> Result<(), ParseError> {
321    match classify_line(line.trimmed) {
322        LineKind::Continuation => {
323            append_continuation(line.trimmed, line.obsolete, line.position, state)?;
324            Ok(())
325        }
326        LineKind::Comment(kind) => {
327            parse_comment_line(line.trimmed, kind, state, file, current_nplurals);
328            Ok(())
329        }
330        LineKind::Keyword(keyword) => parse_keyword_line(
331            line.trimmed,
332            line.obsolete,
333            line.position,
334            keyword,
335            state,
336            file,
337            current_nplurals,
338        ),
339        LineKind::Other => Err(unrecognized_po_line(line.position)),
340    }
341}
342
343fn parse_comment_line<'a>(
344    line_bytes: &'a [u8],
345    kind: CommentKind,
346    state: &mut ParserState<'a>,
347    file: &mut BorrowedPoFile<'a>,
348    current_nplurals: &mut usize,
349) {
350    finish_item(state, file, current_nplurals);
351
352    match kind {
353        CommentKind::Reference => {
354            let reference_line = trimmed_str(&line_bytes[2..]);
355            for_each_reference_token(reference_line, |token| {
356                state.item.references.push(token);
357            });
358        }
359        CommentKind::Flags => {
360            for flag in trimmed_str(&line_bytes[2..]).split(',') {
361                state.item.flags.push(Cow::Borrowed(flag.trim()));
362            }
363        }
364        CommentKind::Extracted => state
365            .item
366            .extracted_comments
367            .push(trimmed_cow(&line_bytes[2..])),
368        CommentKind::Metadata => {
369            let trimmed = trim_ascii(&line_bytes[2..]);
370            if let Some(value_bytes) = ferrocat_mt_metadata_value(trimmed) {
371                state
372                    .item
373                    .metadata
374                    .push((Cow::Borrowed("ferrocat-mt"), trimmed_cow(value_bytes)));
375            } else if let Some((key_bytes, value_bytes)) = split_once_byte(trimmed, b':') {
376                let key = trimmed_cow(key_bytes);
377                if !key.is_empty() {
378                    let value = trimmed_cow(value_bytes);
379                    state.item.metadata.push((key, value));
380                }
381            }
382        }
383        CommentKind::Translator => state.item.comments.push(trimmed_cow(&line_bytes[1..])),
384        CommentKind::Other => {}
385    }
386}
387
388fn ferrocat_mt_metadata_value(trimmed: &[u8]) -> Option<&[u8]> {
389    const KEY: &[u8] = b"ferrocat-mt";
390    let rest = trimmed.strip_prefix(KEY)?;
391    rest.first()
392        .is_some_and(u8::is_ascii_whitespace)
393        .then(|| trim_ascii(rest))
394}
395
396fn parse_keyword_line<'a>(
397    line_bytes: &'a [u8],
398    obsolete: bool,
399    position: ParsePosition,
400    keyword: Keyword,
401    state: &mut ParserState<'a>,
402    file: &mut BorrowedPoFile<'a>,
403    current_nplurals: &mut usize,
404) -> Result<(), ParseError> {
405    match keyword {
406        Keyword::IdPlural => {
407            state
408                .line
409                .mark_keyword(PoLineContext::IdPlural, 0, obsolete);
410            state.item.msgid_plural = Some(at_line_position(
411                extract_quoted_bytes_cow(line_bytes),
412                position,
413            )?);
414        }
415        Keyword::Id => {
416            finish_item(state, file, current_nplurals);
417            state.line.mark_keyword(PoLineContext::Id, 0, obsolete);
418            state.item.msgid = at_line_position(extract_quoted_bytes_cow(line_bytes), position)?;
419        }
420        Keyword::Str => {
421            let plural_index = parse_plural_index(line_bytes).unwrap_or(0);
422            state
423                .line
424                .mark_keyword(PoLineContext::Str, plural_index, obsolete);
425            state.set_msgstr(
426                plural_index,
427                at_line_position(extract_quoted_bytes_cow(line_bytes), position)?,
428            );
429            if is_header_candidate(state) {
430                let headers = at_line_position(parse_header_fragment(line_bytes), position)?;
431                state.header_entries.extend(headers);
432            }
433        }
434        Keyword::Ctxt => {
435            finish_item(state, file, current_nplurals);
436            state.line.mark_keyword(PoLineContext::Ctxt, 0, obsolete);
437            state.item.msgctxt = Some(at_line_position(
438                extract_quoted_bytes_cow(line_bytes),
439                position,
440            )?);
441        }
442    }
443
444    Ok(())
445}
446
447fn append_continuation<'a>(
448    line_bytes: &'a [u8],
449    obsolete: bool,
450    position: ParsePosition,
451    state: &mut ParserState<'a>,
452) -> Result<(), ParseError> {
453    state.line.mark_continuation(obsolete);
454    let value = at_line_position(extract_quoted_bytes_cow(line_bytes), position)?;
455
456    match state.line.context() {
457        Some(PoLineContext::Str) => {
458            state.append_msgstr(state.line.plural_index(), value);
459            if is_header_candidate(state) {
460                let headers = at_line_position(parse_header_fragment(line_bytes), position)?;
461                state.header_entries.extend(headers);
462            }
463        }
464        Some(PoLineContext::Id) => state.item.msgid.to_mut().push_str(value.as_ref()),
465        Some(PoLineContext::IdPlural) => {
466            let target = state.item.msgid_plural.get_or_insert(Cow::Borrowed(""));
467            target.to_mut().push_str(value.as_ref());
468        }
469        Some(PoLineContext::Ctxt) => {
470            let target = state.item.msgctxt.get_or_insert(Cow::Borrowed(""));
471            target.to_mut().push_str(value.as_ref());
472        }
473        None => {}
474    }
475
476    Ok(())
477}
478
479#[inline]
480fn at_line_position<T>(
481    result: Result<T, ParseError>,
482    position: ParsePosition,
483) -> Result<T, ParseError> {
484    result.map_err(|error| error.with_position_if_missing(position))
485}
486
487fn finish_item<'a>(
488    state: &mut ParserState<'a>,
489    file: &mut BorrowedPoFile<'a>,
490    current_nplurals: &mut usize,
491) {
492    if !state.line.has_keyword() {
493        return;
494    }
495
496    if state.item.msgid.is_empty() && !is_header_state(state) {
497        return;
498    }
499
500    if state.line.is_obsolete_item() {
501        state.item.obsolete = true;
502    }
503
504    if is_header_state(state) && file.headers.is_empty() && file.items.is_empty() {
505        file.comments = std::mem::take(&mut state.item.comments).into_vec();
506        file.extracted_comments = std::mem::take(&mut state.item.extracted_comments).into_vec();
507        file.headers = std::mem::take(&mut state.header_entries);
508        *current_nplurals = parse_nplurals(&file.headers).unwrap_or(2);
509        state.reset(*current_nplurals);
510        return;
511    }
512
513    state.materialize_msgstr();
514
515    state.item.msgstr.ensure_singular();
516    if state.item.msgid_plural.is_some() {
517        state
518            .item
519            .msgstr
520            .expand_singular_to_plural_width(state.item.nplurals);
521    }
522
523    state.item.nplurals = *current_nplurals;
524    file.items.push(std::mem::take(&mut state.item));
525    state.reset(*current_nplurals);
526}
527
528fn is_header_state(state: &ParserState<'_>) -> bool {
529    state.item.msgid.is_empty()
530        && state.item.msgctxt.is_none()
531        && state.item.msgid_plural.is_none()
532        && !state.msgstr.is_empty()
533}
534
535fn is_header_candidate(state: &ParserState<'_>) -> bool {
536    state.item.msgid.is_empty()
537        && state.item.msgctxt.is_none()
538        && state.item.msgid_plural.is_none()
539        && state.line.plural_index() == 0
540}
541
542fn parse_header_fragment(line_bytes: &[u8]) -> Result<Vec<BorrowedHeader<'_>>, ParseError> {
543    let Some((start, end)) = find_quoted_bounds(line_bytes) else {
544        return Ok(Vec::new());
545    };
546    let raw = &line_bytes[start..end];
547
548    if header_fragment_is_borrowable(raw) {
549        return Ok(parse_header_fragment_borrowed(raw));
550    }
551
552    parse_header_fragment_owned(line_bytes)
553}
554
555fn parse_header_fragment_borrowed(raw: &[u8]) -> Vec<BorrowedHeader<'_>> {
556    let mut headers = Vec::new();
557    let mut start = 0usize;
558    let mut index = 0usize;
559
560    while index < raw.len() {
561        if raw[index] == b'\\' && raw.get(index + 1) == Some(&b'n') {
562            push_borrowed_header_segment(&raw[start..index], &mut headers);
563            index += 2;
564            start = index;
565            continue;
566        }
567        index += 1;
568    }
569
570    push_borrowed_header_segment(&raw[start..], &mut headers);
571    headers
572}
573
574fn push_borrowed_header_segment<'a>(segment: &'a [u8], out: &mut Vec<BorrowedHeader<'a>>) {
575    if segment.is_empty() {
576        return;
577    }
578    if let Some((key_bytes, value_bytes)) = split_once_byte(segment, b':') {
579        out.push(BorrowedHeader {
580            key: trimmed_cow(key_bytes),
581            value: trimmed_cow(value_bytes),
582        });
583    }
584}
585
586fn parse_header_fragment_owned(line_bytes: &[u8]) -> Result<Vec<BorrowedHeader<'_>>, ParseError> {
587    let decoded = extract_quoted_bytes_cow(line_bytes)?;
588    let mut headers = Vec::new();
589    for segment in decoded.split('\n') {
590        if segment.is_empty() {
591            continue;
592        }
593        if let Some((key, value)) = segment.split_once(':') {
594            headers.push(BorrowedHeader {
595                key: Cow::Owned(key.trim().to_owned()),
596                value: Cow::Owned(value.trim().to_owned()),
597            });
598        }
599    }
600    Ok(headers)
601}
602
603fn header_fragment_is_borrowable(raw: &[u8]) -> bool {
604    let mut index = 0usize;
605    while index < raw.len() {
606        if raw[index] == b'\\' {
607            if raw.get(index + 1) != Some(&b'n') {
608                return false;
609            }
610            index += 2;
611            continue;
612        }
613        index += 1;
614    }
615    !has_byte(b'"', raw)
616}
617
618fn parse_nplurals(headers: &[BorrowedHeader<'_>]) -> Option<usize> {
619    let plural_forms = headers
620        .iter()
621        .find(|header| header.key.as_ref() == "Plural-Forms")?
622        .value
623        .as_bytes();
624    let mut rest = plural_forms;
625
626    while !rest.is_empty() {
627        let (part, next) = match split_once_byte(rest, b';') {
628            Some((part, tail)) => (part, tail),
629            None => (rest, &b""[..]),
630        };
631        let trimmed = trim_ascii(part);
632        if let Some((key, value)) = split_once_byte(trimmed, b'=')
633            && trim_ascii(key) == b"nplurals"
634            && let value = bytes_to_str(trim_ascii(value))
635            && let Ok(parsed) = value.parse::<usize>()
636        {
637            return Some(parsed);
638        }
639        rest = next;
640    }
641
642    None
643}
644
645fn bytes_to_str(bytes: &[u8]) -> &str {
646    input_slice_as_str(bytes)
647}
648
649fn trimmed_str(bytes: &[u8]) -> &str {
650    bytes_to_str(trim_ascii(bytes))
651}
652
653fn trimmed_cow(bytes: &[u8]) -> Cow<'_, str> {
654    Cow::Borrowed(trimmed_str(bytes))
655}
656
657#[cfg(test)]
658mod tests {
659    use std::borrow::Cow;
660
661    use crate::MsgStr;
662
663    use super::{BorrowedMsgStr, parse_po_borrowed};
664
665    #[test]
666    fn borrowed_msgstr_helpers_cover_slot_promotion_and_plural_width() {
667        assert_eq!(BorrowedMsgStr::None.into_owned(), MsgStr::None);
668
669        let mut appended_none = BorrowedMsgStr::None;
670        appended_none.append_slot(0, Cow::Borrowed("one"));
671        assert_eq!(
672            appended_none,
673            BorrowedMsgStr::Singular(Cow::Borrowed("one"))
674        );
675
676        let mut singular = BorrowedMsgStr::None;
677        singular.set_slot(0, Cow::Borrowed("one"));
678        singular.set_slot(0, Cow::Borrowed("uno"));
679        singular.append_slot(0, Cow::Borrowed(" plus"));
680        assert_eq!(
681            singular,
682            BorrowedMsgStr::Singular(Cow::Borrowed("uno plus"))
683        );
684
685        let mut append_empty_plural = BorrowedMsgStr::Plural(Vec::new());
686        append_empty_plural.append_slot(0, Cow::Borrowed("zero"));
687        assert_eq!(
688            append_empty_plural,
689            BorrowedMsgStr::Plural(vec![Cow::Borrowed("zero")])
690        );
691
692        let mut empty_plural = BorrowedMsgStr::Plural(Vec::new());
693        empty_plural.set_slot(0, Cow::Borrowed("zero"));
694        empty_plural.append_slot(0, Cow::Borrowed(" plus"));
695        assert_eq!(
696            empty_plural,
697            BorrowedMsgStr::Plural(vec![Cow::Borrowed("zero plus")])
698        );
699
700        let mut msgstr = BorrowedMsgStr::None;
701
702        msgstr.set_slot(1, Cow::Borrowed("two"));
703        assert_eq!(
704            msgstr,
705            BorrowedMsgStr::Plural(vec![Cow::Borrowed(""), Cow::Borrowed("two")])
706        );
707
708        msgstr.append_slot(1, Cow::Borrowed(" plus"));
709        msgstr.append_slot(0, Cow::Borrowed("one"));
710        assert_eq!(
711            msgstr,
712            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one"), Cow::Borrowed("two plus")])
713        );
714
715        let mut defaulted = BorrowedMsgStr::None;
716        defaulted.ensure_singular();
717        defaulted.expand_singular_to_plural_width(3);
718        assert_eq!(
719            defaulted,
720            BorrowedMsgStr::Plural(vec![
721                Cow::Borrowed(""),
722                Cow::Borrowed(""),
723                Cow::Borrowed(""),
724            ])
725        );
726
727        let mut existing_plural = BorrowedMsgStr::Plural(vec![Cow::Borrowed("one")]);
728        existing_plural.expand_singular_to_plural_width(2);
729        assert_eq!(
730            existing_plural,
731            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one"), Cow::Borrowed("")])
732        );
733    }
734
735    #[test]
736    fn borrows_simple_fields() {
737        let input = r#"
738# translator
739msgid "hello"
740msgstr "world"
741"#;
742
743        let file = parse_po_borrowed(input).expect("borrowed parse");
744        assert_eq!(file.items[0].comments[0], Cow::Borrowed("translator"));
745        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
746        assert_eq!(
747            file.items[0].msgstr,
748            super::BorrowedMsgStr::Singular(Cow::Borrowed("world"))
749        );
750    }
751
752    #[test]
753    fn owns_unescaped_sequences_only_when_needed() {
754        let input = "msgid \"a\\n\"\nmsgstr \"b\\t\"\n";
755        let file = parse_po_borrowed(input).expect("borrowed parse with escapes");
756        assert_eq!(file.items[0].msgid, Cow::<str>::Owned("a\n".to_owned()));
757        assert_eq!(
758            file.items[0].msgstr,
759            super::BorrowedMsgStr::Singular(Cow::<str>::Owned("b\t".to_owned()))
760        );
761    }
762
763    #[test]
764    fn converts_borrowed_parse_to_owned() {
765        let input = "msgid \"hello\"\nmsgstr \"world\"\n";
766        let owned = parse_po_borrowed(input)
767            .expect("borrowed parse")
768            .into_owned();
769        assert_eq!(owned.items[0].msgid, "hello");
770        assert_eq!(owned.items[0].msgstr[0], "world");
771    }
772
773    #[test]
774    fn borrows_header_key_values_without_escapes() {
775        let input = concat!(
776            "msgid \"\"\n",
777            "msgstr \"\"\n",
778            "\"Language: de\\n\"\n",
779            "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
780        );
781        let file = parse_po_borrowed(input).expect("borrowed parse with headers");
782        assert_eq!(file.headers[0].key, Cow::Borrowed("Language"));
783        assert_eq!(file.headers[0].value, Cow::Borrowed("de"));
784    }
785
786    #[test]
787    fn strips_utf8_bom_prefix() {
788        let input = "\u{feff}msgid \"foo\"\nmsgstr \"bar\"\n";
789        let file = parse_po_borrowed(input).expect("borrowed parse");
790
791        assert_eq!(file.items.len(), 1);
792        assert_eq!(file.items[0].msgid, Cow::Borrowed("foo"));
793        assert_eq!(
794            file.items[0].msgstr,
795            super::BorrowedMsgStr::Singular(Cow::Borrowed("bar"))
796        );
797    }
798
799    #[test]
800    fn accepts_crlf_input_for_borrowed_parsing() {
801        let file = parse_po_borrowed("msgid \"foo\"\r\nmsgstr \"bar\"\r\n")
802            .expect("borrowed parse with crlf");
803
804        assert_eq!(file.items[0].msgid, Cow::Borrowed("foo"));
805        assert_eq!(
806            file.items[0].msgstr,
807            super::BorrowedMsgStr::Singular(Cow::Borrowed("bar"))
808        );
809    }
810
811    #[test]
812    fn parse_errors_include_line_position() {
813        let error = parse_po_borrowed("msgid \"ok\"\nmsgstr \"bad\"quote\"\n")
814            .expect_err("unescaped quote should fail");
815        let position = error.position().expect("position metadata");
816
817        assert_eq!(error.message(), "unescaped quote in string literal");
818        assert_eq!(position.offset(), 11);
819        assert_eq!(position.line(), 2);
820        assert_eq!(position.column(), 1);
821    }
822
823    #[test]
824    fn rejects_unrecognized_lines() {
825        let error = parse_po_borrowed("msgid \"ok\"\nmsgstr_ \"typo\"\n")
826            .expect_err("unknown PO line should fail");
827        let position = error.position().expect("position metadata");
828
829        assert_eq!(error.message(), "unrecognized PO syntax");
830        assert_eq!(position.line(), 2);
831        assert_eq!(position.column(), 1);
832    }
833
834    #[test]
835    fn parse_errors_include_line_position_for_plural_and_context_keywords() {
836        let plural_error =
837            parse_po_borrowed("msgid \"file\"\nmsgid_plural \"bad\"quote\"\nmsgstr[0] \"\"\n")
838                .expect_err("unescaped plural quote should fail");
839        let plural_position = plural_error.position().expect("plural position metadata");
840        assert_eq!(plural_error.message(), "unescaped quote in string literal");
841        assert_eq!(plural_position.line(), 2);
842        assert_eq!(plural_position.column(), 1);
843
844        let context_error = parse_po_borrowed("msgctxt \"bad\"quote\"\nmsgid \"x\"\nmsgstr \"\"\n")
845            .expect_err("unescaped context quote should fail");
846        let context_position = context_error.position().expect("context position metadata");
847        assert_eq!(context_error.message(), "unescaped quote in string literal");
848        assert_eq!(context_position.line(), 1);
849        assert_eq!(context_position.column(), 1);
850    }
851
852    #[test]
853    fn parses_owned_header_fragments_in_keyword_and_continuation_lines() {
854        let input = concat!(
855            "msgid \"\"\n",
856            "msgstr \"Project-Id-Version: ferrocat\\t1\\n\"\n",
857            "\"Language: de\\t\\n\"\n",
858        );
859        let file = parse_po_borrowed(input).expect("borrowed parse with owned headers");
860
861        assert_eq!(file.headers.len(), 2);
862        assert_eq!(
863            file.headers[0].key,
864            Cow::<str>::Owned("Project-Id-Version".to_owned())
865        );
866        assert_eq!(
867            file.headers[0].value,
868            Cow::<str>::Owned("ferrocat\t1".to_owned())
869        );
870        assert_eq!(
871            file.headers[1].key,
872            Cow::<str>::Owned("Language".to_owned())
873        );
874        assert_eq!(file.headers[1].value, Cow::<str>::Owned("de".to_owned()));
875    }
876
877    #[test]
878    fn parses_plural_metadata_flags_and_obsolete_items() {
879        let input = concat!(
880            "# translator\n",
881            "#. extracted\n",
882            "#: src/app.rs:1 src/lib.rs:2\n",
883            "#, fuzzy, c-format\n",
884            "#@ domain: admin\n",
885            "msgctxt \"menu\"\n",
886            "msgid \"file\"\n",
887            "msgid_plural \"files\"\n",
888            "msgstr[0] \"Datei\"\n",
889            "msgstr[1] \"Dateien\"\n",
890            "\n",
891            "#~ msgid \"old\"\n",
892            "#~ msgstr \"alt\"\n",
893        );
894
895        let file = parse_po_borrowed(input).expect("borrowed plural parse");
896        assert_eq!(file.items.len(), 2);
897
898        let item = &file.items[0];
899        assert_eq!(item.msgctxt.as_deref(), Some("menu"));
900        assert_eq!(item.msgid_plural.as_deref(), Some("files"));
901        assert_eq!(
902            item.msgstr,
903            BorrowedMsgStr::Plural(vec![Cow::Borrowed("Datei"), Cow::Borrowed("Dateien"),])
904        );
905        assert_eq!(
906            item.references.as_slice(),
907            vec![Cow::Borrowed("src/app.rs:1"), Cow::Borrowed("src/lib.rs:2")].as_slice()
908        );
909        assert_eq!(
910            item.flags.as_slice(),
911            vec![Cow::Borrowed("fuzzy"), Cow::Borrowed("c-format")].as_slice()
912        );
913        assert_eq!(
914            item.metadata.as_slice(),
915            vec![(Cow::Borrowed("domain"), Cow::Borrowed("admin"))].as_slice()
916        );
917
918        assert!(file.items[1].obsolete);
919        assert_eq!(file.items[1].msgid, Cow::Borrowed("old"));
920    }
921
922    #[test]
923    fn parses_owned_headers_and_multiline_fields_when_escapes_are_present() {
924        let input = concat!(
925            "msgid \"\"\n",
926            "msgstr \"\"\n",
927            "\"Project-Id-Version: Demo \\\"Suite\\\"\\n\"\n",
928            "\"Plural-Forms: nplurals=3; plural=(n > 1);\\n\"\n",
929            "\n",
930            "msgctxt \"cta\"\n",
931            "msgid \"hel\"\n",
932            "\"lo\"\n",
933            "msgstr \"wor\"\n",
934            "\"ld\"\n",
935        );
936
937        let file = parse_po_borrowed(input).expect("borrowed parse with owned headers");
938        assert_eq!(
939            file.headers[0],
940            super::BorrowedHeader {
941                key: Cow::Owned("Project-Id-Version".to_owned()),
942                value: Cow::Owned("Demo \"Suite\"".to_owned()),
943            }
944        );
945        assert_eq!(file.items[0].msgid, Cow::Borrowed("hello"));
946        assert_eq!(file.items[0].msgctxt.as_deref(), Some("cta"));
947        assert_eq!(
948            file.items[0].msgstr,
949            BorrowedMsgStr::Singular(Cow::Borrowed("world"))
950        );
951        assert_eq!(file.items[0].nplurals, 3);
952    }
953
954    #[test]
955    fn parses_sparse_plural_slots_and_multiline_context_plural_and_msgstr() {
956        let input = concat!(
957            "msgid \"\"\n",
958            "msgstr \"\"\n",
959            "\"Plural-Forms: nplurals=3; plural=(n > 1);\\n\"\n",
960            "\n",
961            "msgctxt \"ct\"\n",
962            "\"a\"\n",
963            "msgid \"item\"\n",
964            "msgid_plural \"items\"\n",
965            "\" total\"\n",
966            "msgstr[1] \"two\"\n",
967            "\" plus\"\n",
968            "msgstr[0] \"one\"\n",
969            "\" plus\"\n",
970            "\n",
971            "msgid \"missing translation\"\n",
972        );
973
974        let file = parse_po_borrowed(input).expect("parse sparse plural");
975
976        assert_eq!(file.items.len(), 2);
977        let plural = &file.items[0];
978        assert_eq!(plural.msgctxt.as_deref(), Some("cta"));
979        assert_eq!(plural.msgid_plural.as_deref(), Some("items total"));
980        assert_eq!(
981            plural.msgstr,
982            BorrowedMsgStr::Plural(vec![Cow::Borrowed("one plus"), Cow::Borrowed("two plus"),])
983        );
984        assert_eq!(file.items[1].msgid, Cow::Borrowed("missing translation"));
985        assert_eq!(
986            file.items[1].msgstr,
987            BorrowedMsgStr::Singular(Cow::Borrowed(""))
988        );
989    }
990}