1use std::borrow::Cow;
2use std::str;
3
4use crate::scan::{
5 CommentKind, Keyword, LineKind, LineScanner, classify_line, find_byte, find_quoted_bounds,
6 has_byte, parse_plural_index, split_once_byte, trim_ascii,
7};
8use crate::serialize::{write_keyword, write_prefixed_line};
9use crate::text::{escape_string_into, unescape_string, validate_quoted_content};
10use crate::{BorrowedMsgStr, ParseError, SerializeOptions};
11
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct ExtractedMessage<'a> {
14 pub msgctxt: Option<Cow<'a, str>>,
15 pub msgid: Cow<'a, str>,
16 pub msgid_plural: Option<Cow<'a, str>>,
17 pub references: Vec<Cow<'a, str>>,
18 pub extracted_comments: Vec<Cow<'a, str>>,
19 pub flags: Vec<Cow<'a, str>>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
23struct MergeBorrowedFile<'a> {
24 comments: Vec<&'a str>,
25 extracted_comments: Vec<&'a str>,
26 headers: Vec<MergeHeader<'a>>,
27 items: Vec<MergeBorrowedItem<'a>>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31struct MergeHeader<'a> {
32 key: Cow<'a, str>,
33 value: Cow<'a, str>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37struct MergeBorrowedItem<'a> {
38 msgid: Cow<'a, str>,
39 msgctxt: Option<Cow<'a, str>>,
40 references: Vec<&'a str>,
41 msgid_plural: Option<Cow<'a, str>>,
42 msgstr: BorrowedMsgStr<'a>,
43 comments: Vec<&'a str>,
44 extracted_comments: Vec<&'a str>,
45 flags: Vec<&'a str>,
46 metadata: Vec<(&'a str, &'a str)>,
47 obsolete: bool,
48 nplurals: usize,
49}
50
51impl<'a> MergeBorrowedItem<'a> {
52 fn new(nplurals: usize) -> Self {
53 Self {
54 nplurals,
55 ..Self::default()
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum Context {
62 Id,
63 IdPlural,
64 Str,
65 Ctxt,
66}
67
68#[derive(Debug)]
69struct ParserState<'a> {
70 item: MergeBorrowedItem<'a>,
71 header_entries: Vec<MergeHeader<'a>>,
72 msgstr: BorrowedMsgStr<'a>,
73 context: Option<Context>,
74 plural_index: usize,
75 obsolete_line_count: usize,
76 content_line_count: usize,
77 has_keyword: bool,
78}
79
80impl<'a> ParserState<'a> {
81 fn new(nplurals: usize) -> Self {
82 Self {
83 item: MergeBorrowedItem::new(nplurals),
84 header_entries: Vec::new(),
85 msgstr: BorrowedMsgStr::None,
86 context: None,
87 plural_index: 0,
88 obsolete_line_count: 0,
89 content_line_count: 0,
90 has_keyword: false,
91 }
92 }
93
94 fn reset(&mut self, nplurals: usize) {
95 *self = Self::new(nplurals);
96 }
97
98 #[inline]
99 fn reset_after_take(&mut self, nplurals: usize) {
100 self.item.nplurals = nplurals;
101 self.header_entries.clear();
102 self.msgstr = BorrowedMsgStr::None;
103 self.context = None;
104 self.plural_index = 0;
105 self.obsolete_line_count = 0;
106 self.content_line_count = 0;
107 self.has_keyword = false;
108 }
109
110 fn set_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
111 match (&mut self.msgstr, plural_index) {
112 (BorrowedMsgStr::None, 0) => self.msgstr = BorrowedMsgStr::Singular(value),
113 (BorrowedMsgStr::Singular(existing), 0) => *existing = value,
114 (BorrowedMsgStr::Plural(values), 0) => {
115 if values.is_empty() {
116 values.push(Cow::Borrowed(""));
117 }
118 values[0] = value;
119 }
120 _ => {
121 let msgstr = self.promote_plural_msgstr(plural_index);
122 msgstr[plural_index] = value;
123 }
124 }
125 }
126
127 fn append_msgstr(&mut self, plural_index: usize, value: Cow<'a, str>) {
128 match (&mut self.msgstr, plural_index) {
129 (BorrowedMsgStr::None, 0) => self.msgstr = BorrowedMsgStr::Singular(value),
130 (BorrowedMsgStr::Singular(existing), 0) => existing.to_mut().push_str(value.as_ref()),
131 (BorrowedMsgStr::Plural(values), 0) => {
132 if values.is_empty() {
133 values.push(Cow::Borrowed(""));
134 }
135 values[0].to_mut().push_str(value.as_ref());
136 }
137 _ => {
138 let msgstr = self.promote_plural_msgstr(plural_index);
139 msgstr[plural_index].to_mut().push_str(value.as_ref());
140 }
141 }
142 }
143
144 fn materialize_msgstr(&mut self) {
145 self.item.msgstr = std::mem::take(&mut self.msgstr);
146 }
147
148 fn promote_plural_msgstr(&mut self, plural_index: usize) -> &mut Vec<Cow<'a, str>> {
149 if !matches!(self.msgstr, BorrowedMsgStr::Plural(_)) {
150 self.msgstr = match std::mem::take(&mut self.msgstr) {
151 BorrowedMsgStr::None => BorrowedMsgStr::Plural(Vec::with_capacity(2)),
152 BorrowedMsgStr::Singular(value) => BorrowedMsgStr::Plural(vec![value]),
153 BorrowedMsgStr::Plural(values) => BorrowedMsgStr::Plural(values),
154 };
155 }
156 let BorrowedMsgStr::Plural(values) = &mut self.msgstr else {
157 unreachable!("plural msgstr promotion must yield plural storage");
158 };
159 if values.len() <= plural_index {
160 values.resize(plural_index + 1, Cow::Borrowed(""));
161 }
162 values
163 }
164}
165
166#[derive(Debug, Clone, Copy)]
167struct MergeLine<'a> {
168 trimmed: &'a [u8],
169 obsolete: bool,
170}
171
172pub fn merge_catalog<'a>(
173 existing_po: &'a str,
174 extracted_messages: &[ExtractedMessage<'a>],
175) -> Result<String, ParseError> {
176 let normalized;
177 let input = if existing_po.as_bytes().contains(&b'\r') {
178 normalized = existing_po.replace("\r\n", "\n").replace('\r', "\n");
179 normalized.as_str()
180 } else {
181 existing_po
182 };
183
184 let existing = parse_merge_po(input)?;
185 let nplurals = parse_nplurals(&existing.headers).unwrap_or(2);
186 let options = SerializeOptions::default();
187 let mut out = String::with_capacity(estimate_merge_capacity(input, extracted_messages));
188 let mut scratch = String::new();
189
190 write_file_preamble(&mut out, &existing);
191
192 let mut existing_index =
193 std::collections::HashMap::<&str, Vec<(Option<&str>, usize)>>::with_capacity(
194 existing.items.len(),
195 );
196 for (index, item) in existing.items.iter().enumerate() {
197 existing_index
198 .entry(item.msgid.as_ref())
199 .or_default()
200 .push((item.msgctxt.as_deref(), index));
201 }
202
203 let mut matched = vec![false; existing.items.len()];
204 let mut wrote_item = false;
205
206 for extracted in extracted_messages {
207 if wrote_item {
208 out.push('\n');
209 }
210 let existing_index = find_existing_index(
211 &existing_index,
212 extracted.msgctxt.as_deref(),
213 extracted.msgid.as_ref(),
214 );
215
216 match existing_index {
217 Some(index) => {
218 matched[index] = true;
219 write_merged_existing_item(
220 &mut out,
221 &mut scratch,
222 &existing.items[index],
223 extracted,
224 nplurals,
225 &options,
226 );
227 }
228 None => write_new_item(&mut out, &mut scratch, extracted, nplurals, &options),
229 }
230 out.push('\n');
231 wrote_item = true;
232 }
233
234 for (index, item) in existing.items.iter().enumerate() {
235 if matched[index] {
236 continue;
237 }
238 if wrote_item {
239 out.push('\n');
240 }
241 write_existing_item(&mut out, &mut scratch, item, true, &options);
242 out.push('\n');
243 wrote_item = true;
244 }
245
246 Ok(out)
247}
248
249fn parse_merge_po<'a>(input: &'a str) -> Result<MergeBorrowedFile<'a>, ParseError> {
250 let mut file = MergeBorrowedFile::default();
251 file.items.reserve((input.len() / 96).max(1));
252 let mut current_nplurals = 2usize;
253 let mut state = ParserState::new(current_nplurals);
254
255 for line in LineScanner::new(input.as_bytes()) {
256 parse_line(
257 MergeLine {
258 trimmed: line.trimmed,
259 obsolete: line.obsolete,
260 },
261 &mut state,
262 &mut file,
263 &mut current_nplurals,
264 )?;
265 }
266
267 finish_item(&mut state, &mut file, &mut current_nplurals)?;
268 Ok(file)
269}
270
271fn parse_line<'a>(
272 line: MergeLine<'a>,
273 state: &mut ParserState<'a>,
274 file: &mut MergeBorrowedFile<'a>,
275 current_nplurals: &mut usize,
276) -> Result<(), ParseError> {
277 match classify_line(line.trimmed) {
278 LineKind::Continuation => {
279 append_continuation(line.trimmed, line.obsolete, state)?;
280 Ok(())
281 }
282 LineKind::Comment(kind) => {
283 parse_comment_line(line.trimmed, kind, state, file, current_nplurals)
284 }
285 LineKind::Keyword(keyword) => parse_keyword_line(
286 line.trimmed,
287 line.obsolete,
288 keyword,
289 state,
290 file,
291 current_nplurals,
292 ),
293 LineKind::Other => Ok(()),
294 }
295}
296
297fn parse_comment_line<'a>(
298 line_bytes: &'a [u8],
299 kind: CommentKind,
300 state: &mut ParserState<'a>,
301 file: &mut MergeBorrowedFile<'a>,
302 current_nplurals: &mut usize,
303) -> Result<(), ParseError> {
304 finish_item(state, file, current_nplurals)?;
305
306 match kind {
307 CommentKind::Reference => state.item.references.push(trimmed_str(&line_bytes[2..])?),
308 CommentKind::Flags => {
309 for flag in trimmed_str(&line_bytes[2..])?.split(',') {
310 state.item.flags.push(flag.trim());
311 }
312 }
313 CommentKind::Extracted => state
314 .item
315 .extracted_comments
316 .push(trimmed_str(&line_bytes[2..])?),
317 CommentKind::Metadata => {
318 let trimmed = trim_ascii(&line_bytes[2..]);
319 if let Some((key_bytes, value_bytes)) = split_once_byte(trimmed, b':') {
320 let key = trimmed_str(key_bytes)?;
321 if !key.is_empty() {
322 state.item.metadata.push((key, trimmed_str(value_bytes)?));
323 }
324 }
325 }
326 CommentKind::Translator => state.item.comments.push(trimmed_str(&line_bytes[1..])?),
327 CommentKind::Other => {}
328 }
329
330 Ok(())
331}
332
333fn parse_keyword_line<'a>(
334 line_bytes: &'a [u8],
335 obsolete: bool,
336 keyword: Keyword,
337 state: &mut ParserState<'a>,
338 file: &mut MergeBorrowedFile<'a>,
339 current_nplurals: &mut usize,
340) -> Result<(), ParseError> {
341 match keyword {
342 Keyword::IdPlural => {
343 state.obsolete_line_count += usize::from(obsolete);
344 state.item.msgid_plural = Some(extract_merge_quoted_cow(line_bytes)?);
345 state.context = Some(Context::IdPlural);
346 state.content_line_count += 1;
347 state.has_keyword = true;
348 }
349 Keyword::Id => {
350 finish_item(state, file, current_nplurals)?;
351 state.obsolete_line_count += usize::from(obsolete);
352 state.item.msgid = extract_merge_quoted_cow(line_bytes)?;
353 state.context = Some(Context::Id);
354 state.content_line_count += 1;
355 state.has_keyword = true;
356 }
357 Keyword::Str => {
358 let plural_index = parse_plural_index(line_bytes).unwrap_or(0);
359 state.plural_index = plural_index;
360 state.obsolete_line_count += usize::from(obsolete);
361 state.set_msgstr(plural_index, extract_merge_quoted_cow(line_bytes)?);
362 if is_header_candidate(state) {
363 state
364 .header_entries
365 .extend(parse_header_fragment(line_bytes)?);
366 }
367 state.context = Some(Context::Str);
368 state.content_line_count += 1;
369 state.has_keyword = true;
370 }
371 Keyword::Ctxt => {
372 finish_item(state, file, current_nplurals)?;
373 state.obsolete_line_count += usize::from(obsolete);
374 state.item.msgctxt = Some(extract_merge_quoted_cow(line_bytes)?);
375 state.context = Some(Context::Ctxt);
376 state.content_line_count += 1;
377 state.has_keyword = true;
378 }
379 }
380
381 Ok(())
382}
383
384fn append_continuation<'a>(
385 line_bytes: &'a [u8],
386 obsolete: bool,
387 state: &mut ParserState<'a>,
388) -> Result<(), ParseError> {
389 state.obsolete_line_count += usize::from(obsolete);
390 state.content_line_count += 1;
391 let value = extract_merge_quoted_cow(line_bytes)?;
392
393 match state.context {
394 Some(Context::Str) => {
395 state.append_msgstr(state.plural_index, value);
396 if is_header_candidate(state) {
397 state
398 .header_entries
399 .extend(parse_header_fragment(line_bytes)?);
400 }
401 }
402 Some(Context::Id) => state.item.msgid.to_mut().push_str(value.as_ref()),
403 Some(Context::IdPlural) => {
404 let target = state.item.msgid_plural.get_or_insert(Cow::Borrowed(""));
405 target.to_mut().push_str(value.as_ref());
406 }
407 Some(Context::Ctxt) => {
408 let target = state.item.msgctxt.get_or_insert(Cow::Borrowed(""));
409 target.to_mut().push_str(value.as_ref());
410 }
411 None => {}
412 }
413
414 Ok(())
415}
416
417fn finish_item<'a>(
418 state: &mut ParserState<'a>,
419 file: &mut MergeBorrowedFile<'a>,
420 current_nplurals: &mut usize,
421) -> Result<(), ParseError> {
422 if !state.has_keyword {
423 return Ok(());
424 }
425
426 if state.item.msgid.is_empty() && !is_header_state(state) {
427 return Ok(());
428 }
429
430 if state.obsolete_line_count >= state.content_line_count && state.content_line_count > 0 {
431 state.item.obsolete = true;
432 }
433
434 if is_header_state(state) && file.headers.is_empty() && file.items.is_empty() {
435 file.comments = std::mem::take(&mut state.item.comments);
436 file.extracted_comments = std::mem::take(&mut state.item.extracted_comments);
437 file.headers = std::mem::take(&mut state.header_entries);
438 *current_nplurals = parse_nplurals(&file.headers).unwrap_or(2);
439 state.reset(*current_nplurals);
440 return Ok(());
441 }
442
443 state.materialize_msgstr();
444
445 if matches!(state.item.msgstr, BorrowedMsgStr::None) {
446 state.item.msgstr = BorrowedMsgStr::Singular(Cow::Borrowed(""));
447 }
448 if state.item.msgid_plural.is_some() && msgstr_len(&state.item.msgstr) == 1 {
449 let mut values = match std::mem::take(&mut state.item.msgstr) {
450 BorrowedMsgStr::None => Vec::new(),
451 BorrowedMsgStr::Singular(value) => vec![value],
452 BorrowedMsgStr::Plural(values) => values,
453 };
454 values.resize(state.item.nplurals.max(1), Cow::Borrowed(""));
455 state.item.msgstr = BorrowedMsgStr::Plural(values);
456 }
457
458 state.item.nplurals = *current_nplurals;
459 file.items.push(std::mem::take(&mut state.item));
460 state.reset_after_take(*current_nplurals);
461 Ok(())
462}
463
464fn msgstr_len(msgstr: &BorrowedMsgStr<'_>) -> usize {
465 match msgstr {
466 BorrowedMsgStr::None => 0,
467 BorrowedMsgStr::Singular(_) => 1,
468 BorrowedMsgStr::Plural(values) => values.len(),
469 }
470}
471
472fn is_header_state(state: &ParserState<'_>) -> bool {
473 state.item.msgid.is_empty()
474 && state.item.msgctxt.is_none()
475 && state.item.msgid_plural.is_none()
476 && !matches!(state.msgstr, BorrowedMsgStr::None)
477}
478
479fn is_header_candidate(state: &ParserState<'_>) -> bool {
480 state.item.msgid.is_empty()
481 && state.item.msgctxt.is_none()
482 && state.item.msgid_plural.is_none()
483 && state.plural_index == 0
484}
485
486fn parse_header_fragment<'a>(line_bytes: &'a [u8]) -> Result<Vec<MergeHeader<'a>>, ParseError> {
487 let Some(raw) = merge_quoted_raw(line_bytes) else {
488 return Ok(Vec::new());
489 };
490
491 if header_fragment_is_borrowable(raw) {
492 return parse_header_fragment_borrowed(raw);
493 }
494
495 parse_header_fragment_owned(line_bytes)
496}
497
498fn parse_header_fragment_borrowed<'a>(raw: &'a [u8]) -> Result<Vec<MergeHeader<'a>>, ParseError> {
499 let mut headers = Vec::new();
500 let mut start = 0usize;
501 let mut index = 0usize;
502
503 while index < raw.len() {
504 if raw[index] == b'\\' && raw.get(index + 1) == Some(&b'n') {
505 push_borrowed_header_segment(&raw[start..index], &mut headers)?;
506 index += 2;
507 start = index;
508 continue;
509 }
510 index += 1;
511 }
512
513 push_borrowed_header_segment(&raw[start..], &mut headers)?;
514 Ok(headers)
515}
516
517fn push_borrowed_header_segment<'a>(
518 segment: &'a [u8],
519 out: &mut Vec<MergeHeader<'a>>,
520) -> Result<(), ParseError> {
521 if segment.is_empty() {
522 return Ok(());
523 }
524 if let Some((key_bytes, value_bytes)) = split_once_byte(segment, b':') {
525 out.push(MergeHeader {
526 key: Cow::Borrowed(trimmed_str(key_bytes)?),
527 value: Cow::Borrowed(trimmed_str(value_bytes)?),
528 });
529 }
530 Ok(())
531}
532
533fn parse_header_fragment_owned<'a>(
534 line_bytes: &'a [u8],
535) -> Result<Vec<MergeHeader<'a>>, ParseError> {
536 let decoded = extract_merge_quoted_cow(line_bytes)?;
537 let mut headers = Vec::new();
538 for segment in decoded.split('\n') {
539 if segment.is_empty() {
540 continue;
541 }
542 if let Some((key, value)) = segment.split_once(':') {
543 headers.push(MergeHeader {
544 key: Cow::Owned(key.trim().to_owned()),
545 value: Cow::Owned(value.trim().to_owned()),
546 });
547 }
548 }
549 Ok(headers)
550}
551
552fn header_fragment_is_borrowable(raw: &[u8]) -> bool {
553 let mut index = 0usize;
554 while index < raw.len() {
555 if raw[index] == b'\\' {
556 if raw.get(index + 1) != Some(&b'n') {
557 return false;
558 }
559 index += 2;
560 continue;
561 }
562 index += 1;
563 }
564 !has_byte(b'"', raw)
565}
566
567#[inline]
568fn extract_merge_quoted_cow<'a>(line_bytes: &'a [u8]) -> Result<Cow<'a, str>, ParseError> {
569 let Some(raw) = merge_quoted_raw(line_bytes) else {
570 return Ok(Cow::Borrowed(""));
571 };
572
573 validate_quoted_content(raw)?;
574 if !has_byte(b'\\', raw) {
575 return Ok(Cow::Borrowed(bytes_to_str(raw)?));
576 }
577
578 Ok(Cow::Owned(unescape_string(bytes_to_str(raw)?)?))
579}
580
581#[inline]
582fn merge_quoted_raw(line_bytes: &[u8]) -> Option<&[u8]> {
583 let start = match line_bytes.first() {
584 Some(b'"') => 1,
585 _ => find_byte(b'"', line_bytes)? + 1,
586 };
587
588 if start > line_bytes.len() {
589 return None;
590 }
591
592 if line_bytes.len() > start && line_bytes.last() == Some(&b'"') {
593 return Some(&line_bytes[start..line_bytes.len() - 1]);
594 }
595
596 let (quoted_start, quoted_end) = find_quoted_bounds(line_bytes)?;
597 Some(&line_bytes[quoted_start..quoted_end])
598}
599
600fn find_existing_index(
601 existing_index: &std::collections::HashMap<&str, Vec<(Option<&str>, usize)>>,
602 msgctxt: Option<&str>,
603 msgid: &str,
604) -> Option<usize> {
605 let candidates = existing_index.get(msgid)?;
606 candidates
607 .iter()
608 .find_map(|(candidate_ctxt, index)| (*candidate_ctxt == msgctxt).then_some(*index))
609}
610
611fn estimate_merge_capacity(input: &str, extracted_messages: &[ExtractedMessage<'_>]) -> usize {
612 let extracted_bytes: usize = extracted_messages
613 .iter()
614 .map(|message| {
615 message.msgid.len()
616 + message.msgctxt.as_ref().map_or(0, |value| value.len())
617 + message.msgid_plural.as_ref().map_or(0, |value| value.len())
618 + message
619 .references
620 .iter()
621 .map(|value| value.len())
622 .sum::<usize>()
623 + message
624 .extracted_comments
625 .iter()
626 .map(|value| value.len())
627 .sum::<usize>()
628 + message.flags.iter().map(|value| value.len()).sum::<usize>()
629 })
630 .sum();
631
632 input.len() + extracted_bytes + 256
633}
634
635fn write_file_preamble(out: &mut String, file: &MergeBorrowedFile<'_>) {
636 write_prefixed_lines(out, "", "#", &file.comments);
637 write_prefixed_lines(out, "", "#.", &file.extracted_comments);
638
639 out.push_str("msgid \"\"\n");
640 out.push_str("msgstr \"\"\n");
641 for header in &file.headers {
642 out.push('"');
643 escape_string_into(out, header.key.as_ref());
644 out.push_str(": ");
645 escape_string_into(out, header.value.as_ref());
646 out.push_str("\\n\"\n");
647 }
648 out.push('\n');
649}
650
651fn write_merged_existing_item(
652 out: &mut String,
653 scratch: &mut String,
654 existing: &MergeBorrowedItem<'_>,
655 extracted: &ExtractedMessage<'_>,
656 nplurals: usize,
657 options: &SerializeOptions,
658) {
659 let obsolete_prefix = "";
660
661 write_prefixed_lines(out, obsolete_prefix, "#", &existing.comments);
662 write_prefixed_lines(out, obsolete_prefix, "#.", &extracted.extracted_comments);
663 write_metadata_lines(out, obsolete_prefix, &existing.metadata);
664 write_prefixed_lines(out, obsolete_prefix, "#:", &extracted.references);
665 write_merged_flags_line(out, obsolete_prefix, &existing.flags, &extracted.flags);
666
667 if let Some(context) = extracted.msgctxt.as_deref() {
668 write_keyword(
669 out,
670 scratch,
671 obsolete_prefix,
672 "msgctxt",
673 context,
674 None,
675 options,
676 );
677 }
678 write_keyword(
679 out,
680 scratch,
681 obsolete_prefix,
682 "msgid",
683 extracted.msgid.as_ref(),
684 None,
685 options,
686 );
687 if let Some(plural) = extracted.msgid_plural.as_deref() {
688 write_keyword(
689 out,
690 scratch,
691 obsolete_prefix,
692 "msgid_plural",
693 plural,
694 None,
695 options,
696 );
697 }
698
699 write_normalized_msgstr(
700 out,
701 scratch,
702 obsolete_prefix,
703 &existing.msgstr,
704 MsgstrShape {
705 preserve_existing: existing.msgid_plural.is_some() == extracted.msgid_plural.is_some(),
706 plural: extracted.msgid_plural.is_some(),
707 },
708 nplurals,
709 options,
710 );
711}
712
713fn write_new_item(
714 out: &mut String,
715 scratch: &mut String,
716 extracted: &ExtractedMessage<'_>,
717 nplurals: usize,
718 options: &SerializeOptions,
719) {
720 let obsolete_prefix = "";
721
722 write_prefixed_lines(out, obsolete_prefix, "#.", &extracted.extracted_comments);
723 write_prefixed_lines(out, obsolete_prefix, "#:", &extracted.references);
724 write_flags_line(out, obsolete_prefix, &extracted.flags);
725
726 if let Some(context) = extracted.msgctxt.as_deref() {
727 write_keyword(
728 out,
729 scratch,
730 obsolete_prefix,
731 "msgctxt",
732 context,
733 None,
734 options,
735 );
736 }
737 write_keyword(
738 out,
739 scratch,
740 obsolete_prefix,
741 "msgid",
742 extracted.msgid.as_ref(),
743 None,
744 options,
745 );
746 if let Some(plural) = extracted.msgid_plural.as_deref() {
747 write_keyword(
748 out,
749 scratch,
750 obsolete_prefix,
751 "msgid_plural",
752 plural,
753 None,
754 options,
755 );
756 }
757
758 write_default_msgstr(
759 out,
760 scratch,
761 obsolete_prefix,
762 extracted.msgid_plural.is_some(),
763 nplurals,
764 options,
765 );
766}
767
768fn write_existing_item(
769 out: &mut String,
770 scratch: &mut String,
771 item: &MergeBorrowedItem<'_>,
772 obsolete: bool,
773 options: &SerializeOptions,
774) {
775 let obsolete_prefix = if obsolete { "#~ " } else { "" };
776
777 write_prefixed_lines(out, obsolete_prefix, "#", &item.comments);
778 write_prefixed_lines(out, obsolete_prefix, "#.", &item.extracted_comments);
779 write_metadata_lines(out, obsolete_prefix, &item.metadata);
780 write_prefixed_lines(out, obsolete_prefix, "#:", &item.references);
781 write_flags_line(out, obsolete_prefix, &item.flags);
782
783 if let Some(context) = item.msgctxt.as_deref() {
784 write_keyword(
785 out,
786 scratch,
787 obsolete_prefix,
788 "msgctxt",
789 context,
790 None,
791 options,
792 );
793 }
794 write_keyword(
795 out,
796 scratch,
797 obsolete_prefix,
798 "msgid",
799 item.msgid.as_ref(),
800 None,
801 options,
802 );
803 if let Some(plural) = item.msgid_plural.as_deref() {
804 write_keyword(
805 out,
806 scratch,
807 obsolete_prefix,
808 "msgid_plural",
809 plural,
810 None,
811 options,
812 );
813 }
814
815 write_existing_msgstr(
816 out,
817 scratch,
818 obsolete_prefix,
819 &item.msgstr,
820 item.msgid_plural.is_some(),
821 item.nplurals,
822 options,
823 );
824}
825
826fn write_prefixed_lines<T: AsRef<str>>(
827 out: &mut String,
828 obsolete_prefix: &str,
829 prefix: &str,
830 values: &[T],
831) {
832 for value in values {
833 write_prefixed_line(out, obsolete_prefix, prefix, value.as_ref());
834 }
835}
836
837fn write_metadata_lines(out: &mut String, obsolete_prefix: &str, values: &[(&str, &str)]) {
838 for (key, value) in values {
839 out.push_str(obsolete_prefix);
840 out.push_str("#@ ");
841 out.push_str(key);
842 out.push_str(": ");
843 out.push_str(value);
844 out.push('\n');
845 }
846}
847
848fn write_flags_line<T: AsRef<str>>(out: &mut String, obsolete_prefix: &str, values: &[T]) {
849 if values.is_empty() {
850 return;
851 }
852
853 out.push_str(obsolete_prefix);
854 out.push_str("#, ");
855 for (index, value) in values.iter().enumerate() {
856 if index > 0 {
857 out.push(',');
858 }
859 out.push_str(value.as_ref());
860 }
861 out.push('\n');
862}
863
864fn write_merged_flags_line(
865 out: &mut String,
866 obsolete_prefix: &str,
867 existing: &[&str],
868 extracted: &[Cow<'_, str>],
869) {
870 if existing.is_empty() && extracted.is_empty() {
871 return;
872 }
873
874 out.push_str(obsolete_prefix);
875 out.push_str("#, ");
876
877 let mut wrote_any = false;
878 let mut seen = Vec::with_capacity(existing.len() + extracted.len());
879 for flag in existing
880 .iter()
881 .copied()
882 .chain(extracted.iter().map(|value| value.as_ref()))
883 {
884 if seen.contains(&flag) {
885 continue;
886 }
887 if wrote_any {
888 out.push(',');
889 }
890 out.push_str(flag);
891 wrote_any = true;
892 seen.push(flag);
893 }
894 out.push('\n');
895}
896
897fn write_existing_msgstr(
898 out: &mut String,
899 scratch: &mut String,
900 obsolete_prefix: &str,
901 msgstr: &BorrowedMsgStr<'_>,
902 is_plural: bool,
903 nplurals: usize,
904 options: &SerializeOptions,
905) {
906 if is_plural {
907 for index in 0..nplurals.max(1) {
908 let value = match msgstr {
909 BorrowedMsgStr::None => "",
910 BorrowedMsgStr::Singular(value) if index == 0 => value.as_ref(),
911 BorrowedMsgStr::Singular(_) => "",
912 BorrowedMsgStr::Plural(values) => {
913 values.get(index).map_or("", |value| value.as_ref())
914 }
915 };
916 write_keyword(
917 out,
918 scratch,
919 obsolete_prefix,
920 "msgstr",
921 value,
922 Some(index),
923 options,
924 );
925 }
926 return;
927 }
928
929 let value = match msgstr {
930 BorrowedMsgStr::None => "",
931 BorrowedMsgStr::Singular(value) => value.as_ref(),
932 BorrowedMsgStr::Plural(values) => values.first().map_or("", |value| value.as_ref()),
933 };
934 write_keyword(
935 out,
936 scratch,
937 obsolete_prefix,
938 "msgstr",
939 value,
940 None,
941 options,
942 );
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
946struct MsgstrShape {
947 preserve_existing: bool,
948 plural: bool,
949}
950
951fn write_normalized_msgstr(
952 out: &mut String,
953 scratch: &mut String,
954 obsolete_prefix: &str,
955 msgstr: &BorrowedMsgStr<'_>,
956 shape: MsgstrShape,
957 nplurals: usize,
958 options: &SerializeOptions,
959) {
960 if !shape.preserve_existing {
961 write_default_msgstr(
962 out,
963 scratch,
964 obsolete_prefix,
965 shape.plural,
966 nplurals,
967 options,
968 );
969 return;
970 }
971
972 write_existing_msgstr(
973 out,
974 scratch,
975 obsolete_prefix,
976 msgstr,
977 shape.plural,
978 nplurals,
979 options,
980 );
981}
982
983fn write_default_msgstr(
984 out: &mut String,
985 scratch: &mut String,
986 obsolete_prefix: &str,
987 is_plural: bool,
988 nplurals: usize,
989 options: &SerializeOptions,
990) {
991 if is_plural {
992 for index in 0..nplurals.max(1) {
993 write_keyword(
994 out,
995 scratch,
996 obsolete_prefix,
997 "msgstr",
998 "",
999 Some(index),
1000 options,
1001 );
1002 }
1003 return;
1004 }
1005
1006 write_keyword(out, scratch, obsolete_prefix, "msgstr", "", None, options);
1007}
1008
1009fn parse_nplurals(headers: &[MergeHeader<'_>]) -> Option<usize> {
1010 let plural_forms = headers
1011 .iter()
1012 .find(|header| header.key.as_ref() == "Plural-Forms")?
1013 .value
1014 .as_bytes();
1015 let mut rest = plural_forms;
1016
1017 while !rest.is_empty() {
1018 let (part, next) = match split_once_byte(rest, b';') {
1019 Some((part, tail)) => (part, tail),
1020 None => (rest, &b""[..]),
1021 };
1022 let trimmed = trim_ascii(part);
1023 if let Some((key, value)) = split_once_byte(trimmed, b'=')
1024 && trim_ascii(key) == b"nplurals"
1025 && let Ok(value) = bytes_to_str(trim_ascii(value))
1026 && let Ok(parsed) = value.parse::<usize>()
1027 {
1028 return Some(parsed);
1029 }
1030 rest = next;
1031 }
1032
1033 None
1034}
1035
1036fn bytes_to_str(bytes: &[u8]) -> Result<&str, ParseError> {
1037 Ok(unsafe { str::from_utf8_unchecked(bytes) })
1038}
1039
1040fn trimmed_str(bytes: &[u8]) -> Result<&str, ParseError> {
1041 bytes_to_str(trim_ascii(bytes))
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use std::borrow::Cow;
1047
1048 use super::{ExtractedMessage, merge_catalog};
1049 use crate::parse_po;
1050
1051 #[test]
1052 fn preserves_existing_translations_and_updates_references() {
1053 let existing = concat!(
1054 "msgid \"hello\"\n",
1055 "msgstr \"world\"\n\n",
1056 "msgid \"old\"\n",
1057 "msgstr \"alt\"\n",
1058 );
1059 let extracted = vec![ExtractedMessage {
1060 msgid: Cow::Borrowed("hello"),
1061 references: vec![Cow::Borrowed("src/new.rs:10")],
1062 ..ExtractedMessage::default()
1063 }];
1064
1065 let merged = merge_catalog(existing, &extracted).expect("merge");
1066 let reparsed = parse_po(&merged).expect("reparse");
1067 let old_items: Vec<_> = reparsed
1068 .items
1069 .iter()
1070 .filter(|item| item.msgid == "old")
1071 .map(|item| (item.obsolete, item.msgstr[0].clone()))
1072 .collect();
1073 assert_eq!(old_items, vec![(true, "alt".to_owned())]);
1074
1075 let hello = reparsed
1076 .items
1077 .iter()
1078 .find(|item| item.msgid == "hello")
1079 .expect("merged hello item");
1080 assert_eq!(hello.msgstr[0], "world");
1081 assert_eq!(hello.references, vec!["src/new.rs:10".to_owned()]);
1082 }
1083
1084 #[test]
1085 fn creates_new_items_for_new_extracted_messages() {
1086 let merged = merge_catalog(
1087 "",
1088 &[ExtractedMessage {
1089 msgid: Cow::Borrowed("fresh"),
1090 extracted_comments: vec![Cow::Borrowed("from extractor")],
1091 ..ExtractedMessage::default()
1092 }],
1093 )
1094 .expect("merge");
1095 let reparsed = parse_po(&merged).expect("reparse");
1096
1097 assert_eq!(reparsed.items[0].msgid, "fresh");
1098 assert_eq!(reparsed.items[0].msgstr[0], "");
1099 assert_eq!(
1100 reparsed.items[0].extracted_comments,
1101 vec!["from extractor".to_owned()]
1102 );
1103 }
1104
1105 #[test]
1106 fn resets_msgstr_when_switching_between_singular_and_plural() {
1107 let existing = concat!("msgid \"count\"\n", "msgstr \"Anzahl\"\n",);
1108 let extracted = vec![ExtractedMessage {
1109 msgid: Cow::Borrowed("count"),
1110 msgid_plural: Some(Cow::Borrowed("counts")),
1111 ..ExtractedMessage::default()
1112 }];
1113
1114 let merged = merge_catalog(existing, &extracted).expect("merge");
1115 let reparsed = parse_po(&merged).expect("reparse");
1116
1117 assert!(reparsed.items[0].msgid_plural.is_some());
1118 assert_eq!(reparsed.items[0].msgstr.len(), 2);
1119 assert_eq!(reparsed.items[0].msgstr[0], "");
1120 assert_eq!(reparsed.items[0].msgstr[1], "");
1121 }
1122}