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#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct BorrowedPoFile<'a> {
16 pub comments: Vec<Cow<'a, str>>,
18 pub extracted_comments: Vec<Cow<'a, str>>,
20 pub headers: Vec<BorrowedHeader<'a>>,
22 pub items: Vec<BorrowedPoItem<'a>>,
24}
25
26impl BorrowedPoFile<'_> {
27 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct BorrowedHeader<'a> {
54 pub key: Cow<'a, str>,
56 pub value: Cow<'a, str>,
58}
59
60impl BorrowedHeader<'_> {
61 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
73pub struct BorrowedPoItem<'a> {
74 pub msgid: Cow<'a, str>,
76 pub msgctxt: Option<Cow<'a, str>>,
78 pub references: PoVec<Cow<'a, str>>,
80 pub msgid_plural: Option<Cow<'a, str>>,
82 pub msgstr: BorrowedMsgStr<'a>,
84 pub comments: PoVec<Cow<'a, str>>,
86 pub extracted_comments: PoVec<Cow<'a, str>>,
88 pub flags: PoVec<Cow<'a, str>>,
90 pub metadata: PoVec<(Cow<'a, str>, Cow<'a, str>)>,
92 pub obsolete: bool,
94 pub nplurals: usize,
96}
97
98impl BorrowedPoItem<'_> {
99 fn new(nplurals: usize) -> Self {
100 Self {
101 nplurals,
102 ..Self::default()
103 }
104 }
105
106 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
135pub enum BorrowedMsgStr<'a> {
136 #[default]
138 None,
139 Singular(Cow<'a, str>),
141 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 len(&self) -> usize {
153 match self {
154 Self::None => 0,
155 Self::Singular(_) => 1,
156 Self::Plural(values) => values.len(),
157 }
158 }
159
160 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 #[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 header_entries_faithful: bool,
265 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 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 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
356pub 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
620fn 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
660fn 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}