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(str::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 requires_folding = options.fold_length > 0
332        && parts_with_has_next(text).any(|(part, has_next)| {
333            let escaped_len = escaped_part_len(part, has_next);
334            let limit = if has_multiple_lines {
335                other_line_max
336            } else {
337                first_line_max
338            };
339            escaped_len > limit
340        });
341    let use_compact = options.compact_multiline
342        && text.split('\n').next().unwrap_or_default() != ""
343        && !requires_folding;
344    let mut wrote_first_value_line = if use_compact {
345        false
346    } else {
347        out.push_str(obsolete_prefix);
348        push_keyword_prefix(out, keyword, index);
349        out.push_str("\"\"\n");
350        true
351    };
352
353    for (part, has_next) in parts_with_has_next(text) {
354        scratch.clear();
355        escape_string_into(scratch, part);
356        if has_next {
357            scratch.push_str("\\n");
358        }
359
360        let limit = if wrote_first_value_line || has_multiple_lines {
361            other_line_max
362        } else {
363            first_line_max
364        };
365
366        write_folded_segments(
367            out,
368            obsolete_prefix,
369            keyword,
370            index,
371            scratch,
372            limit,
373            &mut wrote_first_value_line,
374        );
375    }
376}
377
378fn parts_with_has_next(input: &str) -> impl Iterator<Item = (&str, bool)> {
379    input.split_inclusive('\n').map(|part| {
380        part.strip_suffix('\n')
381            .map_or((part, false), |stripped| (stripped, true))
382    })
383}
384
385fn write_folded_segments(
386    out: &mut String,
387    obsolete_prefix: &str,
388    keyword: &str,
389    index: Option<usize>,
390    input: &str,
391    max_len: usize,
392    wrote_first_value_line: &mut bool,
393) {
394    let mut start = 0;
395    loop {
396        let end = folded_split_point(input, start, max_len);
397        write_quoted_segment(
398            out,
399            obsolete_prefix,
400            keyword,
401            index,
402            &input[start..end],
403            wrote_first_value_line,
404        );
405        if end == input.len() {
406            break;
407        }
408        start = end;
409    }
410}
411
412fn write_quoted_segment(
413    out: &mut String,
414    obsolete_prefix: &str,
415    keyword: &str,
416    index: Option<usize>,
417    segment: &str,
418    wrote_first_value_line: &mut bool,
419) {
420    out.push_str(obsolete_prefix);
421    if !*wrote_first_value_line {
422        push_keyword_prefix(out, keyword, index);
423        *wrote_first_value_line = true;
424    }
425    out.push('"');
426    out.push_str(segment);
427    out.push_str("\"\n");
428}
429
430fn escaped_part_len(part: &str, has_next: bool) -> usize {
431    let escaped_len = escaped_string_len(part);
432
433    escaped_len + if has_next { 2 } else { 0 }
434}
435
436fn escaped_string_len(input: &str) -> usize {
437    let bytes = input.as_bytes();
438    let Some(mut escape_index) = find_escapable_byte(bytes) else {
439        return input.len();
440    };
441
442    let mut escaped_len = input.len() + 1;
443    while let Some(relative) = find_escapable_byte(&bytes[escape_index + 1..]) {
444        escape_index += 1 + relative;
445        escaped_len += 1;
446    }
447    escaped_len
448}
449
450fn folded_split_point(input: &str, start: usize, max_len: usize) -> usize {
451    let remaining = input.len() - start;
452    if remaining <= max_len {
453        return input.len();
454    }
455
456    let mut end = start;
457    while end < input.len() {
458        let chunk_end = next_fold_chunk_end(input, end);
459        let next_len = chunk_end - start;
460        if next_len > max_len {
461            break;
462        }
463        end = chunk_end;
464    }
465
466    if end > start {
467        return end;
468    }
469
470    let end = clamp_char_boundary(input, start, start + max_len);
471    if input.as_bytes()[end - 1] == b'\\' {
472        end - 1
473    } else {
474        end
475    }
476}
477
478fn next_fold_chunk_end(input: &str, start: usize) -> usize {
479    let bytes = input.as_bytes();
480    let is_space = bytes[start] == b' ';
481    let mut end = start + 1;
482    while end < bytes.len() && (bytes[end] == b' ') == is_space {
483        end += 1;
484    }
485    end
486}
487
488fn clamp_char_boundary(input: &str, start: usize, requested_end: usize) -> usize {
489    let mut end = requested_end.min(input.len());
490    while end > start && !input.is_char_boundary(end) {
491        end -= 1;
492    }
493    if end > start {
494        return end;
495    }
496
497    let mut end = requested_end.min(input.len());
498    while end < input.len() && !input.is_char_boundary(end) {
499        end += 1;
500    }
501    end
502}
503
504#[cfg(test)]
505mod tests {
506    use crate::{Header, MsgStr, PoFile, PoItem, SerializeOptions, escape_string, parse_po};
507
508    use super::{escaped_string_len, stringify_po};
509
510    #[test]
511    fn serializes_comments_headers_and_items() {
512        let file = PoFile {
513            comments: vec!["Translator comment".to_owned()],
514            extracted_comments: vec!["Extracted".to_owned()],
515            headers: vec![
516                Header {
517                    key: "Language".to_owned(),
518                    value: "de".to_owned(),
519                },
520                Header {
521                    key: "Plural-Forms".to_owned(),
522                    value: "nplurals=2; plural=(n != 1);".to_owned(),
523                },
524            ],
525            items: vec![PoItem {
526                msgid: "Line1\nLine2".to_owned(),
527                msgstr: MsgStr::from(vec!["Zeile1\nZeile2".to_owned()]),
528                ..PoItem::new(2)
529            }],
530        };
531
532        let output = stringify_po(&file, &SerializeOptions::default());
533        assert!(output.contains("# Translator comment\n"));
534        assert!(output.contains("#. Extracted\n"));
535        assert!(output.contains("\"Language: de\\n\"\n"));
536        assert!(output.contains("msgid \"Line1\\n\"\n\"Line2\"\n"));
537        assert!(output.contains("msgstr \"Zeile1\\n\"\n\"Zeile2\"\n"));
538    }
539
540    #[test]
541    fn serializes_empty_plural_translations() {
542        let file = PoFile {
543            headers: vec![],
544            comments: vec![],
545            extracted_comments: vec![],
546            items: vec![PoItem {
547                msgid: "item".to_owned(),
548                msgid_plural: Some("items".to_owned()),
549                nplurals: 3,
550                ..PoItem::new(3)
551            }],
552        };
553
554        let output = stringify_po(&file, &SerializeOptions::default());
555        assert!(output.contains("msgstr[0] \"\"\n"));
556        assert!(output.contains("msgstr[1] \"\"\n"));
557        assert!(output.contains("msgstr[2] \"\"\n"));
558    }
559
560    #[test]
561    fn serializes_non_compact_multiline_values() {
562        let file = PoFile {
563            headers: vec![],
564            comments: vec![],
565            extracted_comments: vec![],
566            items: vec![PoItem {
567                msgid: "\nIndented".to_owned(),
568                msgstr: MsgStr::from(vec!["\nUebersetzt".to_owned()]),
569                ..PoItem::new(2)
570            }],
571        };
572
573        let output = stringify_po(
574            &file,
575            &SerializeOptions {
576                compact_multiline: false,
577                ..SerializeOptions::default()
578            },
579        );
580
581        assert!(output.contains("msgid \"\"\n\"\\n\"\n\"Indented\"\n"));
582        assert!(output.contains("msgstr \"\"\n\"\\n\"\n\"Uebersetzt\"\n"));
583    }
584
585    #[test]
586    fn does_not_fold_when_fold_length_is_zero() {
587        let file = PoFile {
588            headers: vec![],
589            comments: vec![],
590            extracted_comments: vec![],
591            items: vec![PoItem {
592                msgid: "Alpha beta gamma delta".to_owned(),
593                msgstr: MsgStr::from(vec!["Uno dos tres cuatro".to_owned()]),
594                ..PoItem::new(2)
595            }],
596        };
597
598        let output = stringify_po(
599            &file,
600            &SerializeOptions {
601                fold_length: 0,
602                compact_multiline: true,
603            },
604        );
605
606        assert!(output.contains("msgid \"Alpha beta gamma delta\"\n"));
607        assert!(output.contains("msgstr \"Uno dos tres cuatro\"\n"));
608    }
609
610    #[test]
611    fn folds_utf8_without_splitting_codepoints() {
612        let file = PoFile {
613            headers: vec![],
614            comments: vec![],
615            extracted_comments: vec![],
616            items: vec![PoItem {
617                msgid: "Grüße aus Köln".to_owned(),
618                msgstr: MsgStr::from(vec!["Übermäßig höflich".to_owned()]),
619                ..PoItem::new(2)
620            }],
621        };
622
623        let output = stringify_po(
624            &file,
625            &SerializeOptions {
626                fold_length: 12,
627                compact_multiline: true,
628            },
629        );
630
631        let reparsed = parse_po(&output).expect("reparse folded utf8 output");
632        assert_eq!(reparsed.items[0].msgid, "Grüße aus Köln");
633        assert_eq!(reparsed.items[0].msgstr[0], "Übermäßig höflich");
634    }
635
636    #[test]
637    fn escaped_string_len_matches_rendered_escape_length() {
638        let input = "plain\tquote\"slash\\newline\ncarriage\r";
639
640        assert_eq!(escaped_string_len(input), escape_string(input).len());
641        assert_eq!(escaped_string_len("plain"), "plain".len());
642    }
643
644    #[test]
645    fn drops_previous_msgid_history_on_roundtrip() {
646        let input = r#"#| msgctxt "Old menu context"
647#| msgid "Old file label"
648msgctxt "menu"
649msgid "File"
650msgstr "Datei"
651"#;
652
653        let parsed = parse_po(input).expect("parse previous-msgid input");
654        assert_eq!(parsed.items.len(), 1);
655        assert_eq!(parsed.items[0].msgctxt.as_deref(), Some("menu"));
656        assert_eq!(parsed.items[0].msgid, "File");
657
658        let output = stringify_po(&parsed, &SerializeOptions::default());
659        assert!(!output.contains("#| "));
660        assert!(output.contains("msgctxt \"menu\"\n"));
661        assert!(output.contains("msgid \"File\"\n"));
662    }
663
664    #[test]
665    fn normalizes_headerless_files_with_explicit_empty_header() {
666        let file = PoFile {
667            headers: vec![],
668            comments: vec![],
669            extracted_comments: vec![],
670            items: vec![PoItem {
671                msgid: "Save".to_owned(),
672                msgstr: MsgStr::from("Speichern".to_owned()),
673                flags: vec!["fuzzy".to_owned()].into(),
674                ..PoItem::new(2)
675            }],
676        };
677
678        let output = stringify_po(&file, &SerializeOptions::default());
679        assert!(output.starts_with("msgid \"\"\nmsgstr \"\"\n\n"));
680        assert!(output.contains("#, fuzzy\nmsgid \"Save\"\nmsgstr \"Speichern\"\n"));
681    }
682
683    #[test]
684    fn folds_single_line_values_like_gettext_style_multiline_entries() {
685        let file = PoFile {
686            headers: vec![],
687            comments: vec!["test wrapping".to_owned()],
688            extracted_comments: vec![],
689            items: vec![
690                PoItem {
691                    msgid: "Some line that contain special characters \" and that \t is very, very, very long...: %s \n".to_owned(),
692                    msgstr: MsgStr::from(vec!["".to_owned()]),
693                    ..PoItem::new(2)
694                },
695                PoItem {
696                    msgid: "Some line that contain special characters \"foobar\" and that contains whitespace at the end          ".to_owned(),
697                    msgstr: MsgStr::from(vec!["".to_owned()]),
698                    ..PoItem::new(2)
699                },
700            ],
701        };
702
703        let output = stringify_po(
704            &file,
705            &SerializeOptions {
706                fold_length: 50,
707                compact_multiline: true,
708            },
709        );
710
711        assert!(output.contains("msgid \"\"\n\"Some line that contain special characters \\\" and\"\n\" that \\t is very, very, very long...: %s \\n\"\n"));
712        assert!(output.contains("msgid \"\"\n\"Some line that contain special characters \"\n\"\\\"foobar\\\" and that contains whitespace at the \"\n\"end          \"\n"));
713    }
714}