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#[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 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 #[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
280pub 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}