Skip to main content

ferrocat_po/
merge.rs

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}