Skip to main content

ferrocat_po/
serialize.rs

1use crate::scan::find_escapable_byte;
2use crate::text::{escape_string_into, escape_string_into_with_first_escape};
3use crate::{PoFile, PoItem, SerializeOptions};
4
5/// Serializes a [`PoFile`] back into gettext PO text.
6#[must_use]
7pub fn stringify_po(file: &PoFile, options: &SerializeOptions) -> String {
8    let mut out = String::with_capacity(estimate_capacity(file));
9    let mut scratch = String::new();
10
11    for comment in &file.comments {
12        push_prefixed_comment(&mut out, "#", comment);
13    }
14    for comment in &file.extracted_comments {
15        push_prefixed_comment(&mut out, "#.", comment);
16    }
17
18    out.push_str("msgid \"\"\n");
19    out.push_str("msgstr \"\"\n");
20    for header in &file.headers {
21        out.push('"');
22        append_escaped(&mut out, &header.key);
23        out.push_str(": ");
24        append_escaped(&mut out, &header.value);
25        out.push_str("\\n");
26        out.push_str("\"\n");
27    }
28    out.push('\n');
29
30    let mut iter = file.items.iter().peekable();
31    while let Some(item) = iter.next() {
32        write_item(&mut out, &mut scratch, item, options);
33        if iter.peek().is_some() {
34            out.push('\n');
35        }
36    }
37
38    out
39}
40
41fn estimate_capacity(file: &PoFile) -> usize {
42    let headers_len: usize = file
43        .headers
44        .iter()
45        .map(|header| header.key.len() + header.value.len() + 8)
46        .sum();
47    let items_len: usize = file
48        .items
49        .iter()
50        .map(|item| {
51            item.msgid.len()
52                + item.msgctxt.as_ref().map_or(0, String::len)
53                + item.msgid_plural.as_ref().map_or(0, String::len)
54                + item.msgstr.iter().map(String::len).sum::<usize>()
55                + item.comments.iter().map(String::len).sum::<usize>()
56                + item
57                    .extracted_comments
58                    .iter()
59                    .map(String::len)
60                    .sum::<usize>()
61                + item.references.iter().map(String::len).sum::<usize>()
62                + item.flags.iter().map(String::len).sum::<usize>()
63        })
64        .sum();
65
66    headers_len + items_len + 256
67}
68
69fn push_prefixed_comment(out: &mut String, prefix: &str, comment: &str) {
70    out.push_str(prefix);
71    if !comment.is_empty() {
72        out.push(' ');
73        out.push_str(comment);
74    }
75    out.push('\n');
76}
77
78fn write_item(out: &mut String, scratch: &mut String, item: &PoItem, options: &SerializeOptions) {
79    let obsolete_prefix = if item.obsolete { "#~ " } else { "" };
80
81    for comment in &item.comments {
82        write_prefixed_line(out, obsolete_prefix, "#", comment);
83    }
84    for comment in &item.extracted_comments {
85        write_prefixed_line(out, obsolete_prefix, "#.", comment);
86    }
87    for (key, value) in &item.metadata {
88        out.push_str(obsolete_prefix);
89        write_metadata_line(out, key, value);
90        out.push('\n');
91    }
92    for reference in &item.references {
93        out.push_str(obsolete_prefix);
94        out.push_str("#: ");
95        out.push_str(reference);
96        out.push('\n');
97    }
98    if !item.flags.is_empty() {
99        out.push_str(obsolete_prefix);
100        out.push_str("#, ");
101        for (index, flag) in item.flags.iter().enumerate() {
102            if index > 0 {
103                out.push(',');
104            }
105            out.push_str(flag);
106        }
107        out.push('\n');
108    }
109
110    if let Some(context) = &item.msgctxt {
111        write_keyword(
112            out,
113            scratch,
114            obsolete_prefix,
115            "msgctxt",
116            context,
117            None,
118            options,
119        );
120    }
121    write_keyword(
122        out,
123        scratch,
124        obsolete_prefix,
125        "msgid",
126        &item.msgid,
127        None,
128        options,
129    );
130    if let Some(plural) = &item.msgid_plural {
131        write_keyword(
132            out,
133            scratch,
134            obsolete_prefix,
135            "msgid_plural",
136            plural,
137            None,
138            options,
139        );
140    }
141
142    if item.msgid_plural.is_some() && item.msgstr.is_empty() {
143        let count = item.nplurals.max(1);
144        for index in 0..count {
145            write_keyword(
146                out,
147                scratch,
148                obsolete_prefix,
149                "msgstr",
150                "",
151                Some(index),
152                options,
153            );
154        }
155        return;
156    }
157
158    if item.msgstr.is_empty() {
159        write_keyword(out, scratch, obsolete_prefix, "msgstr", "", None, options);
160        return;
161    }
162
163    let indexed = item.msgid_plural.is_some() || item.msgstr.len() > 1;
164    for (index, value) in item.msgstr.iter().enumerate() {
165        write_keyword(
166            out,
167            scratch,
168            obsolete_prefix,
169            "msgstr",
170            value,
171            if indexed { Some(index) } else { None },
172            options,
173        );
174    }
175}
176
177fn write_metadata_line(out: &mut String, key: &str, value: &str) {
178    out.push_str("#@ ");
179    out.push_str(key);
180    if key == "ferrocat-mt" {
181        out.push(' ');
182    } else {
183        out.push_str(": ");
184    }
185    out.push_str(value);
186}
187
188pub fn write_prefixed_line(out: &mut String, obsolete_prefix: &str, prefix: &str, value: &str) {
189    out.push_str(obsolete_prefix);
190    out.push_str(prefix);
191    if !value.is_empty() {
192        out.push(' ');
193        out.push_str(value);
194    }
195    out.push('\n');
196}
197
198pub fn write_keyword(
199    out: &mut String,
200    scratch: &mut String,
201    obsolete_prefix: &str,
202    keyword: &str,
203    value: &str,
204    index: Option<usize>,
205    options: &SerializeOptions,
206) {
207    if try_write_simple_keyword(out, obsolete_prefix, keyword, value, index, options) {
208        return;
209    }
210
211    write_complex_keyword(
212        out,
213        scratch,
214        obsolete_prefix,
215        keyword,
216        value,
217        index,
218        options,
219    );
220}
221
222fn try_write_simple_keyword(
223    out: &mut String,
224    obsolete_prefix: &str,
225    keyword: &str,
226    value: &str,
227    index: Option<usize>,
228    options: &SerializeOptions,
229) -> bool {
230    let first_escape = find_escapable_byte(value.as_bytes());
231    if matches!(first_escape, Some(index) if value.as_bytes()[index] == b'\n') {
232        return false;
233    }
234
235    let prefix_len = keyword_prefix_len(keyword, index);
236    if options.fold_length > 0
237        && value.len()
238            > options
239                .fold_length
240                .saturating_sub(obsolete_prefix.len() + prefix_len + 2)
241    {
242        return false;
243    }
244
245    let start_len = out.len();
246    out.reserve(obsolete_prefix.len() + prefix_len + value.len() + 3);
247    out.push_str(obsolete_prefix);
248    push_keyword_prefix(out, keyword, index);
249    out.push('"');
250    escape_string_into_with_first_escape(out, value, first_escape);
251    out.push_str("\"\n");
252
253    if options.fold_length > 0 && out.len() - start_len - 1 > options.fold_length {
254        out.truncate(start_len);
255        return false;
256    }
257
258    true
259}
260
261fn keyword_prefix_len(keyword: &str, index: Option<usize>) -> usize {
262    index.map_or_else(
263        || keyword.len() + 1,
264        |value| keyword.len() + digits(value) + 3,
265    )
266}
267
268fn push_keyword_prefix(out: &mut String, keyword: &str, index: Option<usize>) {
269    out.push_str(keyword);
270    if let Some(value) = index {
271        out.push('[');
272        push_usize(out, value);
273        out.push(']');
274    }
275    out.push(' ');
276}
277
278fn push_usize(out: &mut String, mut value: usize) {
279    if value == 0 {
280        out.push('0');
281        return;
282    }
283
284    let mut buf = [0u8; 20];
285    let mut len = 0usize;
286    while value > 0 {
287        let digit = u8::try_from(value % 10).expect("single decimal digit fits in u8");
288        buf[len] = b'0' + digit;
289        len += 1;
290        value /= 10;
291    }
292    for index in (0..len).rev() {
293        out.push(char::from(buf[index]));
294    }
295}
296
297const fn digits(mut value: usize) -> usize {
298    let mut count = 1usize;
299    while value >= 10 {
300        value /= 10;
301        count += 1;
302    }
303    count
304}
305
306fn append_escaped(out: &mut String, input: &str) {
307    escape_string_into(out, input);
308}
309
310fn write_complex_keyword(
311    out: &mut String,
312    scratch: &mut String,
313    obsolete_prefix: &str,
314    keyword: &str,
315    text: &str,
316    index: Option<usize>,
317    options: &SerializeOptions,
318) {
319    let prefix_len = keyword_prefix_len(keyword, index);
320    let has_multiple_lines = text.contains('\n');
321    let first_line_max = if options.fold_length == 0 {
322        usize::MAX
323    } else {
324        options.fold_length.saturating_sub(prefix_len + 2).max(1)
325    };
326    let other_line_max = if options.fold_length == 0 {
327        usize::MAX
328    } else {
329        options.fold_length.saturating_sub(2).max(1)
330    };
331    let parts = parts_with_has_next(text).collect::<Vec<_>>();
332    let requires_folding = options.fold_length > 0
333        && parts.iter().any(|(part, has_next)| {
334            let escaped_len = escaped_part_len(part, *has_next);
335            let limit = if has_multiple_lines {
336                other_line_max
337            } else {
338                first_line_max
339            };
340            escaped_len > limit
341        });
342    let use_compact = options.compact_multiline
343        && text.split('\n').next().unwrap_or_default() != ""
344        && !requires_folding;
345    let mut wrote_first_value_line = if use_compact {
346        false
347    } else {
348        out.push_str(obsolete_prefix);
349        push_keyword_prefix(out, keyword, index);
350        out.push_str("\"\"\n");
351        true
352    };
353
354    for (part, has_next) in parts {
355        scratch.clear();
356        escape_string_into(scratch, part);
357        if has_next {
358            scratch.push_str("\\n");
359        }
360
361        let limit = if wrote_first_value_line || has_multiple_lines {
362            other_line_max
363        } else {
364            first_line_max
365        };
366
367        write_folded_segments(
368            out,
369            obsolete_prefix,
370            keyword,
371            index,
372            scratch,
373            limit,
374            &mut wrote_first_value_line,
375        );
376    }
377}
378
379fn parts_with_has_next(input: &str) -> impl Iterator<Item = (&str, bool)> {
380    input.split_inclusive('\n').map(|part| {
381        part.strip_suffix('\n')
382            .map_or((part, false), |stripped| (stripped, true))
383    })
384}
385
386fn write_folded_segments(
387    out: &mut String,
388    obsolete_prefix: &str,
389    keyword: &str,
390    index: Option<usize>,
391    input: &str,
392    max_len: usize,
393    wrote_first_value_line: &mut bool,
394) {
395    let mut start = 0;
396    loop {
397        let end = folded_split_point(input, start, max_len);
398        write_quoted_segment(
399            out,
400            obsolete_prefix,
401            keyword,
402            index,
403            &input[start..end],
404            wrote_first_value_line,
405        );
406        if end == input.len() {
407            break;
408        }
409        start = end;
410    }
411}
412
413fn write_quoted_segment(
414    out: &mut String,
415    obsolete_prefix: &str,
416    keyword: &str,
417    index: Option<usize>,
418    segment: &str,
419    wrote_first_value_line: &mut bool,
420) {
421    out.push_str(obsolete_prefix);
422    if !*wrote_first_value_line {
423        push_keyword_prefix(out, keyword, index);
424        *wrote_first_value_line = true;
425    }
426    out.push('"');
427    out.push_str(segment);
428    out.push_str("\"\n");
429}
430
431fn escaped_part_len(part: &str, has_next: bool) -> usize {
432    let escaped_len = match find_escapable_byte(part.as_bytes()) {
433        Some(_) => {
434            let mut escaped = String::new();
435            escape_string_into(&mut escaped, part);
436            escaped.len()
437        }
438        None => part.len(),
439    };
440
441    escaped_len + if has_next { 2 } else { 0 }
442}
443
444fn folded_split_point(input: &str, start: usize, max_len: usize) -> usize {
445    let remaining = input.len() - start;
446    if remaining <= max_len {
447        return input.len();
448    }
449
450    let mut end = start;
451    while end < input.len() {
452        let chunk_end = next_fold_chunk_end(input, end);
453        let next_len = chunk_end - start;
454        if next_len > max_len {
455            break;
456        }
457        end = chunk_end;
458    }
459
460    if end > start {
461        return end;
462    }
463
464    let end = clamp_char_boundary(input, start, start + max_len);
465    if input.as_bytes()[end - 1] == b'\\' {
466        end - 1
467    } else {
468        end
469    }
470}
471
472fn next_fold_chunk_end(input: &str, start: usize) -> usize {
473    let bytes = input.as_bytes();
474    let is_space = bytes[start] == b' ';
475    let mut end = start + 1;
476    while end < bytes.len() && (bytes[end] == b' ') == is_space {
477        end += 1;
478    }
479    end
480}
481
482fn clamp_char_boundary(input: &str, start: usize, requested_end: usize) -> usize {
483    let mut end = requested_end.min(input.len());
484    while end > start && !input.is_char_boundary(end) {
485        end -= 1;
486    }
487    if end > start {
488        return end;
489    }
490
491    let mut end = requested_end.min(input.len());
492    while end < input.len() && !input.is_char_boundary(end) {
493        end += 1;
494    }
495    end
496}
497
498#[cfg(test)]
499mod tests {
500    use crate::{Header, MsgStr, PoFile, PoItem, SerializeOptions, parse_po};
501
502    use super::stringify_po;
503
504    #[test]
505    fn serializes_comments_headers_and_items() {
506        let file = PoFile {
507            comments: vec!["Translator comment".to_owned()],
508            extracted_comments: vec!["Extracted".to_owned()],
509            headers: vec![
510                Header {
511                    key: "Language".to_owned(),
512                    value: "de".to_owned(),
513                },
514                Header {
515                    key: "Plural-Forms".to_owned(),
516                    value: "nplurals=2; plural=(n != 1);".to_owned(),
517                },
518            ],
519            items: vec![PoItem {
520                msgid: "Line1\nLine2".to_owned(),
521                msgstr: MsgStr::from(vec!["Zeile1\nZeile2".to_owned()]),
522                ..PoItem::new(2)
523            }],
524        };
525
526        let output = stringify_po(&file, &SerializeOptions::default());
527        assert!(output.contains("# Translator comment\n"));
528        assert!(output.contains("#. Extracted\n"));
529        assert!(output.contains("\"Language: de\\n\"\n"));
530        assert!(output.contains("msgid \"Line1\\n\"\n\"Line2\"\n"));
531        assert!(output.contains("msgstr \"Zeile1\\n\"\n\"Zeile2\"\n"));
532    }
533
534    #[test]
535    fn serializes_empty_plural_translations() {
536        let file = PoFile {
537            headers: vec![],
538            comments: vec![],
539            extracted_comments: vec![],
540            items: vec![PoItem {
541                msgid: "item".to_owned(),
542                msgid_plural: Some("items".to_owned()),
543                nplurals: 3,
544                ..PoItem::new(3)
545            }],
546        };
547
548        let output = stringify_po(&file, &SerializeOptions::default());
549        assert!(output.contains("msgstr[0] \"\"\n"));
550        assert!(output.contains("msgstr[1] \"\"\n"));
551        assert!(output.contains("msgstr[2] \"\"\n"));
552    }
553
554    #[test]
555    fn serializes_non_compact_multiline_values() {
556        let file = PoFile {
557            headers: vec![],
558            comments: vec![],
559            extracted_comments: vec![],
560            items: vec![PoItem {
561                msgid: "\nIndented".to_owned(),
562                msgstr: MsgStr::from(vec!["\nUebersetzt".to_owned()]),
563                ..PoItem::new(2)
564            }],
565        };
566
567        let output = stringify_po(
568            &file,
569            &SerializeOptions {
570                compact_multiline: false,
571                ..SerializeOptions::default()
572            },
573        );
574
575        assert!(output.contains("msgid \"\"\n\"\\n\"\n\"Indented\"\n"));
576        assert!(output.contains("msgstr \"\"\n\"\\n\"\n\"Uebersetzt\"\n"));
577    }
578
579    #[test]
580    fn does_not_fold_when_fold_length_is_zero() {
581        let file = PoFile {
582            headers: vec![],
583            comments: vec![],
584            extracted_comments: vec![],
585            items: vec![PoItem {
586                msgid: "Alpha beta gamma delta".to_owned(),
587                msgstr: MsgStr::from(vec!["Uno dos tres cuatro".to_owned()]),
588                ..PoItem::new(2)
589            }],
590        };
591
592        let output = stringify_po(
593            &file,
594            &SerializeOptions {
595                fold_length: 0,
596                compact_multiline: true,
597            },
598        );
599
600        assert!(output.contains("msgid \"Alpha beta gamma delta\"\n"));
601        assert!(output.contains("msgstr \"Uno dos tres cuatro\"\n"));
602    }
603
604    #[test]
605    fn folds_utf8_without_splitting_codepoints() {
606        let file = PoFile {
607            headers: vec![],
608            comments: vec![],
609            extracted_comments: vec![],
610            items: vec![PoItem {
611                msgid: "Grüße aus Köln".to_owned(),
612                msgstr: MsgStr::from(vec!["Übermäßig höflich".to_owned()]),
613                ..PoItem::new(2)
614            }],
615        };
616
617        let output = stringify_po(
618            &file,
619            &SerializeOptions {
620                fold_length: 12,
621                compact_multiline: true,
622            },
623        );
624
625        let reparsed = parse_po(&output).expect("reparse folded utf8 output");
626        assert_eq!(reparsed.items[0].msgid, "Grüße aus Köln");
627        assert_eq!(reparsed.items[0].msgstr[0], "Übermäßig höflich");
628    }
629
630    #[test]
631    fn drops_previous_msgid_history_on_roundtrip() {
632        let input = r#"#| msgctxt "Old menu context"
633#| msgid "Old file label"
634msgctxt "menu"
635msgid "File"
636msgstr "Datei"
637"#;
638
639        let parsed = parse_po(input).expect("parse previous-msgid input");
640        assert_eq!(parsed.items.len(), 1);
641        assert_eq!(parsed.items[0].msgctxt.as_deref(), Some("menu"));
642        assert_eq!(parsed.items[0].msgid, "File");
643
644        let output = stringify_po(&parsed, &SerializeOptions::default());
645        assert!(!output.contains("#| "));
646        assert!(output.contains("msgctxt \"menu\"\n"));
647        assert!(output.contains("msgid \"File\"\n"));
648    }
649
650    #[test]
651    fn normalizes_headerless_files_with_explicit_empty_header() {
652        let file = PoFile {
653            headers: vec![],
654            comments: vec![],
655            extracted_comments: vec![],
656            items: vec![PoItem {
657                msgid: "Save".to_owned(),
658                msgstr: MsgStr::from("Speichern".to_owned()),
659                flags: vec!["fuzzy".to_owned()],
660                ..PoItem::new(2)
661            }],
662        };
663
664        let output = stringify_po(&file, &SerializeOptions::default());
665        assert!(output.starts_with("msgid \"\"\nmsgstr \"\"\n\n"));
666        assert!(output.contains("#, fuzzy\nmsgid \"Save\"\nmsgstr \"Speichern\"\n"));
667    }
668
669    #[test]
670    fn folds_single_line_values_like_gettext_style_multiline_entries() {
671        let file = PoFile {
672            headers: vec![],
673            comments: vec!["test wrapping".to_owned()],
674            extracted_comments: vec![],
675            items: vec![
676                PoItem {
677                    msgid: "Some line that contain special characters \" and that \t is very, very, very long...: %s \n".to_owned(),
678                    msgstr: MsgStr::from(vec!["".to_owned()]),
679                    ..PoItem::new(2)
680                },
681                PoItem {
682                    msgid: "Some line that contain special characters \"foobar\" and that contains whitespace at the end          ".to_owned(),
683                    msgstr: MsgStr::from(vec!["".to_owned()]),
684                    ..PoItem::new(2)
685                },
686            ],
687        };
688
689        let output = stringify_po(
690            &file,
691            &SerializeOptions {
692                fold_length: 50,
693                compact_multiline: true,
694            },
695        );
696
697        assert!(output.contains("msgid \"\"\n\"Some line that contain special characters \\\" and\"\n\" that \\t is very, very, very long...: %s \\n\"\n"));
698        assert!(output.contains("msgid \"\"\n\"Some line that contain special characters \"\n\"\\\"foobar\\\" and that contains whitespace at the \"\n\"end          \"\n"));
699    }
700}