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