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(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}