1use memchr::{memchr_iter, memchr2_iter};
2
3use crate::line_state::{PoLineContext, PoLineState};
4use crate::scan::{
5 CommentKind, Keyword, LineKind, LineScanner, classify_line, parse_plural_index,
6 split_once_byte, trim_ascii, unrecognized_po_line,
7};
8use crate::text::{extract_quoted_bytes_cow, for_each_reference_token};
9use crate::utf8::input_slice_as_str;
10use crate::{Header, MsgStr, ParseError, ParsePosition, PoFile, PoItem};
11
12#[derive(Debug)]
13struct ParserState {
14 item: PoItem,
15 msgstr: MsgStr,
16 line: PoLineState,
17}
18
19impl ParserState {
20 fn new(nplurals: usize) -> Self {
21 Self {
22 item: PoItem::new(nplurals),
23 msgstr: MsgStr::None,
24 line: PoLineState::default(),
25 }
26 }
27
28 fn reset(&mut self, nplurals: usize) {
29 self.item.clear_for_reuse(nplurals);
30 self.reset_after_take(nplurals);
31 }
32
33 fn reset_after_take(&mut self, nplurals: usize) {
34 self.item.nplurals = nplurals;
35 self.msgstr = MsgStr::None;
36 self.line.reset();
37 }
38
39 fn set_msgstr(&mut self, plural_index: usize, value: String) {
40 match (&mut self.msgstr, plural_index) {
41 (MsgStr::None, 0) => self.msgstr = MsgStr::Singular(value),
42 (MsgStr::Singular(existing), 0) => *existing = value,
43 (MsgStr::Plural(values), 0) => {
44 if values.is_empty() {
45 values.push(String::new());
46 }
47 values[0] = value;
48 }
49 _ => {
50 let msgstr = self.promote_plural_msgstr(plural_index);
51 msgstr[plural_index] = value;
52 }
53 }
54 }
55
56 fn append_msgstr(&mut self, plural_index: usize, value: &str) {
57 match (&mut self.msgstr, plural_index) {
58 (MsgStr::None, 0) => self.msgstr = MsgStr::Singular(value.to_owned()),
59 (MsgStr::Singular(existing), 0) => existing.push_str(value),
60 (MsgStr::Plural(values), 0) => {
61 if values.is_empty() {
62 values.push(String::new());
63 }
64 values[0].push_str(value);
65 }
66 _ => {
67 let msgstr = self.promote_plural_msgstr(plural_index);
68 msgstr[plural_index].push_str(value);
69 }
70 }
71 }
72
73 fn header_msgstr(&self) -> &str {
74 self.msgstr.first().unwrap_or_default()
75 }
76
77 fn materialize_msgstr(&mut self) {
78 debug_assert!(self.item.msgstr.is_empty());
79 self.item.msgstr = core::mem::take(&mut self.msgstr);
80 }
81
82 fn promote_plural_msgstr(&mut self, plural_index: usize) -> &mut Vec<String> {
83 if !matches!(self.msgstr, MsgStr::Plural(_)) {
84 self.msgstr = match core::mem::take(&mut self.msgstr) {
85 MsgStr::None => MsgStr::Plural(Vec::with_capacity(2)),
86 MsgStr::Singular(value) => {
87 let mut values = Vec::with_capacity(2);
88 values.push(value);
89 MsgStr::Plural(values)
90 }
91 MsgStr::Plural(values) => MsgStr::Plural(values),
92 };
93 }
94 let MsgStr::Plural(msgstr) = &mut self.msgstr else {
95 unreachable!("plural msgstr promotion must yield plural storage");
96 };
97 if msgstr.len() <= plural_index {
98 msgstr.resize(plural_index + 1, String::new());
99 }
100 msgstr
101 }
102}
103
104#[derive(Debug, Clone, Copy)]
105struct BorrowedLine<'a> {
106 trimmed: &'a [u8],
107 obsolete: bool,
108 position: ParsePosition,
109}
110
111pub fn parse_po(input: &str) -> Result<PoFile, ParseError> {
120 let input = strip_utf8_bom(input);
121
122 let mut file = PoFile::default();
123 file.items.reserve((input.len() / 96).max(1));
124 let mut current_nplurals = 2;
125 let mut state = ParserState::new(current_nplurals);
126
127 for line in LineScanner::new(input.as_bytes()) {
128 parse_line(
129 BorrowedLine {
130 trimmed: line.trimmed,
131 obsolete: line.obsolete,
132 position: line.position,
133 },
134 &mut state,
135 &mut file,
136 &mut current_nplurals,
137 )?;
138 }
139
140 finish_item(&mut state, &mut file, &mut current_nplurals);
141
142 Ok(file)
143}
144
145pub fn parse_po_bytes(input: &[u8]) -> Result<PoFile, ParseError> {
157 reject_unsupported_declared_charset(input)?;
158
159 let input = std::str::from_utf8(input).map_err(|error| {
160 ParseError::new(format!(
161 "PO input is not valid UTF-8 at byte {}",
162 error.valid_up_to()
163 ))
164 })?;
165
166 parse_po(input)
167}
168
169#[inline]
170fn strip_utf8_bom(input: &str) -> &str {
171 input.strip_prefix('\u{feff}').unwrap_or(input)
172}
173
174fn reject_unsupported_declared_charset(input: &[u8]) -> Result<(), ParseError> {
175 let Some(charset) = declared_charset(input) else {
176 return Ok(());
177 };
178
179 if charset.eq_ignore_ascii_case("utf-8") || charset.eq_ignore_ascii_case("utf8") {
180 return Ok(());
181 }
182
183 Err(ParseError::new(format!(
184 "unsupported PO charset `{charset}`; parse_po_bytes accepts UTF-8 input"
185 )))
186}
187
188fn declared_charset(input: &[u8]) -> Option<&str> {
189 const CONTENT_TYPE: &[u8] = b"content-type:";
190 const CHARSET: &[u8] = b"charset=";
191
192 if let Some(content_type_start) = find_ascii_case(input, CONTENT_TYPE) {
193 let line_end = input[content_type_start..]
194 .iter()
195 .position(|byte| matches!(byte, b'\n' | b'\r'))
196 .map_or(input.len(), |relative_end| {
197 content_type_start + relative_end
198 });
199
200 let line = &input[content_type_start..line_end];
201 let charset_start = find_ascii_case(line, CHARSET)? + CHARSET.len();
202
203 let value_end = line[charset_start..]
204 .iter()
205 .position(|byte| {
206 matches!(byte, b'\\' | b'"' | b'\'' | b';') || byte.is_ascii_whitespace()
207 })
208 .map_or(line.len(), |relative| charset_start + relative);
209
210 if value_end == charset_start {
211 return None;
212 }
213
214 return std::str::from_utf8(&line[charset_start..value_end]).ok();
215 }
216
217 None
218}
219
220fn find_ascii_case(haystack: &[u8], needle: &[u8]) -> Option<usize> {
221 let Some((&first, rest)) = needle.split_first() else {
222 return Some(0);
223 };
224
225 let matches_at = |index: usize| {
226 haystack
227 .get(index + 1..index + 1 + rest.len())
228 .is_some_and(|tail| tail.eq_ignore_ascii_case(rest))
229 };
230
231 if first.is_ascii_alphabetic() {
232 let lower = first.to_ascii_lowercase();
233 let upper = first.to_ascii_uppercase();
234 memchr2_iter(lower, upper, haystack).find(|&index| matches_at(index))
235 } else {
236 memchr_iter(first, haystack).find(|&index| matches_at(index))
237 }
238}
239
240fn parse_line(
241 line: BorrowedLine<'_>,
242 state: &mut ParserState,
243 file: &mut PoFile,
244 current_nplurals: &mut usize,
245) -> Result<(), ParseError> {
246 match classify_line(line.trimmed) {
247 LineKind::Continuation => {
248 append_continuation(line.trimmed, line.obsolete, line.position, state)?;
249 Ok(())
250 }
251 LineKind::Comment(kind) => {
252 parse_comment_line(line.trimmed, kind, state, file, current_nplurals);
253 Ok(())
254 }
255 LineKind::Keyword(keyword) => parse_keyword_line(
256 line.trimmed,
257 line.obsolete,
258 line.position,
259 keyword,
260 state,
261 file,
262 current_nplurals,
263 ),
264 LineKind::Other => Err(unrecognized_po_line(line.position)),
265 }
266}
267
268fn parse_comment_line(
269 line_bytes: &[u8],
270 kind: CommentKind,
271 state: &mut ParserState,
272 file: &mut PoFile,
273 current_nplurals: &mut usize,
274) {
275 finish_item(state, file, current_nplurals);
276
277 match kind {
278 CommentKind::Reference => {
279 let reference_line = trimmed_str(&line_bytes[2..]);
280 for_each_reference_token(reference_line, |token| {
281 state.item.references.push(token.into_owned());
282 });
283 }
284 CommentKind::Flags => {
285 for flag in trimmed_str(&line_bytes[2..]).split(',') {
286 state.item.flags.push(flag.trim().to_owned());
287 }
288 }
289 CommentKind::Extracted => state
290 .item
291 .extracted_comments
292 .push(trimmed_string(&line_bytes[2..])),
293 CommentKind::Metadata => {
294 let trimmed = trim_ascii(&line_bytes[2..]);
295 if let Some(value_bytes) = ferrocat_mt_metadata_value(trimmed) {
296 state.item.metadata.push((
297 "ferrocat-mt".to_owned(),
298 trimmed_str(value_bytes).to_owned(),
299 ));
300 } else if let Some((key_bytes, value_bytes)) = split_once_byte(trimmed, b':') {
301 let key = trimmed_str(key_bytes);
302 if !key.is_empty() {
303 let value = trimmed_str(value_bytes);
304 state.item.metadata.push((key.to_owned(), value.to_owned()));
305 }
306 }
307 }
308 CommentKind::Translator => state.item.comments.push(trimmed_string(&line_bytes[1..])),
309 CommentKind::Other => {}
310 }
311}
312
313fn ferrocat_mt_metadata_value(trimmed: &[u8]) -> Option<&[u8]> {
314 const KEY: &[u8] = b"ferrocat-mt";
315 let rest = trimmed.strip_prefix(KEY)?;
316 rest.first()
317 .is_some_and(u8::is_ascii_whitespace)
318 .then(|| trim_ascii(rest))
319}
320
321fn parse_keyword_line(
322 line_bytes: &[u8],
323 obsolete: bool,
324 position: ParsePosition,
325 keyword: Keyword,
326 state: &mut ParserState,
327 file: &mut PoFile,
328 current_nplurals: &mut usize,
329) -> Result<(), ParseError> {
330 match keyword {
331 Keyword::IdPlural => {
332 state
333 .line
334 .mark_keyword(PoLineContext::IdPlural, 0, obsolete);
335 state.item.msgid_plural = Some(
336 at_line_position(extract_quoted_bytes_cow(line_bytes), position)?.into_owned(),
337 );
338 }
339 Keyword::Id => {
340 finish_item(state, file, current_nplurals);
341 state.line.mark_keyword(PoLineContext::Id, 0, obsolete);
342 state.item.msgid =
343 at_line_position(extract_quoted_bytes_cow(line_bytes), position)?.into_owned();
344 }
345 Keyword::Str => {
346 let plural_index = parse_plural_index(line_bytes).unwrap_or(0);
347 state
348 .line
349 .mark_keyword(PoLineContext::Str, plural_index, obsolete);
350 state.set_msgstr(
351 plural_index,
352 at_line_position(extract_quoted_bytes_cow(line_bytes), position)?.into_owned(),
353 );
354 }
355 Keyword::Ctxt => {
356 finish_item(state, file, current_nplurals);
357 state.line.mark_keyword(PoLineContext::Ctxt, 0, obsolete);
358 state.item.msgctxt = Some(
359 at_line_position(extract_quoted_bytes_cow(line_bytes), position)?.into_owned(),
360 );
361 }
362 }
363
364 Ok(())
365}
366
367fn append_continuation(
368 line_bytes: &[u8],
369 obsolete: bool,
370 position: ParsePosition,
371 state: &mut ParserState,
372) -> Result<(), ParseError> {
373 state.line.mark_continuation(obsolete);
374 let value = at_line_position(extract_quoted_bytes_cow(line_bytes), position)?;
375
376 match state.line.context() {
377 Some(PoLineContext::Str) => {
378 state.append_msgstr(state.line.plural_index(), value.as_ref());
379 }
380 Some(PoLineContext::Id) => state.item.msgid.push_str(value.as_ref()),
381 Some(PoLineContext::IdPlural) => {
382 let target = state.item.msgid_plural.get_or_insert_with(String::new);
383 target.push_str(value.as_ref());
384 }
385 Some(PoLineContext::Ctxt) => {
386 let target = state.item.msgctxt.get_or_insert_with(String::new);
387 target.push_str(value.as_ref());
388 }
389 None => {}
390 }
391
392 Ok(())
393}
394
395#[inline]
396fn at_line_position<T>(
397 result: Result<T, ParseError>,
398 position: ParsePosition,
399) -> Result<T, ParseError> {
400 result.map_err(|error| error.with_position_if_missing(position))
401}
402
403fn finish_item(state: &mut ParserState, file: &mut PoFile, current_nplurals: &mut usize) {
404 if !state.line.has_keyword() {
405 return;
406 }
407
408 if state.item.msgid.is_empty() && !is_header_state(state) {
409 return;
410 }
411
412 if state.line.is_obsolete_item() {
413 state.item.obsolete = true;
414 }
415
416 if is_header_state(state) && file.headers.is_empty() && file.items.is_empty() {
417 file.comments = core::mem::take(&mut state.item.comments).into_vec();
418 file.extracted_comments = core::mem::take(&mut state.item.extracted_comments).into_vec();
419 parse_headers(state.header_msgstr(), &mut file.headers);
420 *current_nplurals = parse_nplurals(&file.headers).unwrap_or(2);
421 state.reset(*current_nplurals);
422 return;
423 }
424
425 state.materialize_msgstr();
426
427 if state.item.msgstr.is_empty() {
428 state.item.msgstr = MsgStr::Singular(String::new());
429 }
430 if state.item.msgid_plural.is_some() && state.item.msgstr.len() == 1 {
431 let mut values = core::mem::take(&mut state.item.msgstr).into_vec();
432 values.resize(state.item.nplurals.max(1), String::new());
433 state.item.msgstr = MsgStr::Plural(values);
434 }
435
436 state.item.nplurals = *current_nplurals;
437 file.items.push(core::mem::take(&mut state.item));
438 state.reset_after_take(*current_nplurals);
439}
440
441fn is_header_state(state: &ParserState) -> bool {
442 state.item.msgid.is_empty()
443 && state.item.msgctxt.is_none()
444 && state.item.msgid_plural.is_none()
445 && !state.msgstr.is_empty()
446}
447
448fn parse_headers(raw: &str, out: &mut Vec<Header>) {
449 let bytes = raw.as_bytes();
450 out.reserve(memchr_iter(b'\n', bytes).count() + 1);
451
452 for line in LineScanner::new(bytes) {
453 if let Some((key_bytes, value_bytes)) = split_once_byte(line.trimmed, b':') {
454 out.push(Header {
455 key: trimmed_string(key_bytes),
456 value: trimmed_string(value_bytes),
457 });
458 }
459 }
460}
461
462fn parse_nplurals(headers: &[Header]) -> Option<usize> {
463 let plural_forms = headers
464 .iter()
465 .find(|header| header.key == "Plural-Forms")?
466 .value
467 .as_bytes();
468 let mut rest = plural_forms;
469
470 while !rest.is_empty() {
471 let (part, next) = match split_once_byte(rest, b';') {
472 Some((part, tail)) => (part, tail),
473 None => (rest, &b""[..]),
474 };
475 let trimmed = trim_ascii(part);
476 if let Some((key, value)) = split_once_byte(trimmed, b'=')
477 && trim_ascii(key) == b"nplurals"
478 && let value = bytes_to_str(trim_ascii(value))
479 && let Ok(parsed) = value.parse::<usize>()
480 {
481 return Some(parsed);
482 }
483 rest = next;
484 }
485
486 None
487}
488
489fn bytes_to_str(bytes: &[u8]) -> &str {
490 input_slice_as_str(bytes)
491}
492
493fn trimmed_str(bytes: &[u8]) -> &str {
494 bytes_to_str(trim_ascii(bytes))
495}
496
497fn trimmed_string(bytes: &[u8]) -> String {
498 trimmed_str(bytes).to_owned()
499}
500
501#[cfg(test)]
502mod tests {
503 use super::{declared_charset, find_ascii_case, parse_po, parse_po_bytes};
504
505 const MULTI_LINE: &str = r#"# French translation of Link (6.x-2.9)
506# Copyright (c) 2011 by the French translation team
507#
508## Plural-Forms by polish translation team to demonstrate multi-line ##
509#
510msgid ""
511msgstr ""
512"Project-Id-Version: Link (6.x-2.9)\n"
513"POT-Creation-Date: 2011-12-31 23:39+0000\n"
514"PO-Revision-Date: 2013-12-17 14:21+0100\n"
515"Language-Team: French\n"
516"MIME-Version: 1.0\n"
517"Content-Type: text/plain; charset=UTF-8\n"
518"Content-Transfer-Encoding: 8bit\n"
519"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
520"|| n%100>=20) ? 1 : 2;\n"
521"Last-Translator: Ruben Vermeersch <ruben@rocketeer.be>\n"
522"Language: fr\n"
523"X-Generator: Poedit 1.6.2\n"
524
525msgid ""
526"The following placeholder tokens can be used in both paths and titles. When "
527"used in a path or title, they will be replaced with the appropriate values."
528msgstr ""
529"Les ébauches de jetons suivantes peuvent être utilisées à la fois dans les "
530"chemins et in the titles. Lorsqu'elles sont utilisées dans un chemin ou un "
531"titre, elles seront remplacées par les valeurs appropriées."
532"#;
533
534 const COMMENTED: &str = r#"msgid ""
535msgstr ""
536"Project-Id-Version: Test\n"
537"Plural-Forms: nplurals=2; plural=(n != 1);\n"
538
539#: .tmp/ui/settings/views/console-modal.html
540msgid "{{dataLoader.data.length}} results"
541msgstr "{{dataLoader.data.length}} resultaten"
542
543#~ msgid "Add order"
544#~ msgstr "Order toevoegen"
545
546#~ # commented obsolete item
547#~ #, fuzzy
548#~ msgid "Commented item"
549#~ msgstr "not sure"
550
551# commented obsolete item
552#, fuzzy
553#~ msgid "Second commented item"
554#~ msgstr "also not sure"
555"#;
556
557 const C_STRINGS: &str = r#"msgid ""
558msgstr ""
559"Plural-Forms: nplurals=2; plural=(n > 1);\n"
560
561msgid "The name field must not contain characters like \" or \\"
562msgstr ""
563
564msgid ""
565"%1$s\n"
566"%2$s %3$s\n"
567"%4$s\n"
568"%5$s"
569msgstr ""
570
571msgid ""
572"define('some/test/module', function () {\n"
573"\t'use strict';\n"
574"\treturn {};\n"
575"});\n"
576""
577msgstr ""
578"#;
579
580 #[test]
581 fn parses_multiline_headers_and_items() {
582 let po = match parse_po(MULTI_LINE) {
583 Ok(value) => value,
584 Err(error) => panic!("parse failed: {error}"),
585 };
586
587 assert_eq!(po.headers[6].key, "Content-Transfer-Encoding");
588 assert_eq!(
589 po.headers
590 .iter()
591 .find(|header| header.key == "Plural-Forms")
592 .map(|header| header.value.as_str()),
593 Some(
594 "nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;"
595 )
596 );
597 assert_eq!(po.items.len(), 1);
598 assert_eq!(
599 po.items[0].msgid,
600 "The following placeholder tokens can be used in both paths and titles. When used in a path or title, they will be replaced with the appropriate values."
601 );
602 }
603
604 #[test]
605 fn parses_c_string_escapes_and_multiline_values() {
606 let po = match parse_po(C_STRINGS) {
607 Ok(value) => value,
608 Err(error) => panic!("parse failed: {error}"),
609 };
610
611 assert_eq!(
612 po.items[0].msgid,
613 "The name field must not contain characters like \" or \\"
614 );
615 assert_eq!(po.items[1].msgid, "%1$s\n%2$s %3$s\n%4$s\n%5$s");
616 assert_eq!(
617 po.items[2].msgid,
618 "define('some/test/module', function () {\n\t'use strict';\n\treturn {};\n});\n"
619 );
620 }
621
622 #[test]
623 fn parse_errors_include_line_position() {
624 let error = parse_po("msgid \"ok\"\n msgstr \"bad\"quote\"\n")
625 .expect_err("unescaped quote should fail");
626 let position = error.position().expect("position metadata");
627
628 assert_eq!(error.message(), "unescaped quote in string literal");
629 assert_eq!(position.offset(), 13);
630 assert_eq!(position.line(), 2);
631 assert_eq!(position.column(), 3);
632 }
633
634 #[test]
635 fn rejects_unrecognized_lines() {
636 let error =
637 parse_po("msgid \"ok\"\nmsgstr_ \"typo\"\n").expect_err("unknown PO line should fail");
638 let position = error.position().expect("position metadata");
639
640 assert_eq!(error.message(), "unrecognized PO syntax");
641 assert_eq!(position.line(), 2);
642 assert_eq!(position.column(), 1);
643 }
644
645 #[test]
646 fn parses_obsolete_items() {
647 let po = match parse_po(COMMENTED) {
648 Ok(value) => value,
649 Err(error) => panic!("parse failed: {error}"),
650 };
651
652 assert_eq!(po.items.len(), 4);
653 assert!(!po.items[0].obsolete);
654 assert!(po.items[1].obsolete);
655 assert!(po.items[2].obsolete);
656 assert!(po.items[3].obsolete);
657 assert_eq!(
658 po.items[3].comments.as_slice(),
659 vec!["commented obsolete item".to_owned()].as_slice()
660 );
661 assert_eq!(
662 po.items[3].flags.as_slice(),
663 vec!["fuzzy".to_owned()].as_slice()
664 );
665 }
666
667 #[test]
668 fn parses_context_without_creating_phantom_items() {
669 let input = r#"msgid ""
670msgstr ""
671"Language: de\n"
672
673msgctxt "menu"
674msgid "File"
675msgstr "Datei"
676"#;
677
678 let po = match parse_po(input) {
679 Ok(value) => value,
680 Err(error) => panic!("parse failed: {error}"),
681 };
682
683 assert_eq!(po.items.len(), 1);
684 assert_eq!(po.items[0].msgctxt.as_deref(), Some("menu"));
685 assert_eq!(po.items[0].msgid, "File");
686 }
687
688 #[test]
689 fn strips_utf8_bom_prefix() {
690 let input = "\u{feff}msgid \"foo\"\nmsgstr \"bar\"\n";
691 let po = parse_po(input).expect("parse");
692
693 assert_eq!(po.items.len(), 1);
694 assert_eq!(po.items[0].msgid, "foo");
695 assert_eq!(po.items[0].msgstr[0], "bar");
696 }
697
698 #[test]
699 fn parse_po_bytes_accepts_utf8_po_content() {
700 let input = b"msgid \"foo\"\nmsgstr \"bar\"\n";
701 let po = parse_po_bytes(input).expect("parse bytes");
702
703 assert_eq!(po.items.len(), 1);
704 assert_eq!(po.items[0].msgid, "foo");
705 assert_eq!(po.items[0].msgstr[0], "bar");
706 }
707
708 #[test]
709 fn parse_po_bytes_accepts_declared_utf8_charset() {
710 let input = b"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\n";
711 let po = parse_po_bytes(input).expect("parse bytes");
712
713 assert_eq!(po.headers[0].key, "Content-Type");
714 assert_eq!(po.headers[0].value, "text/plain; charset=UTF-8");
715 }
716
717 #[test]
718 fn parse_po_bytes_rejects_declared_non_utf8_charset() {
719 let input =
720 b"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n\n";
721 let error = parse_po_bytes(input).expect_err("non-utf8 charset should fail");
722
723 assert!(error.message().contains("unsupported PO charset"));
724 assert!(error.message().contains("ISO-8859-1"));
725 }
726
727 #[test]
728 fn parse_po_bytes_reports_invalid_utf8_input() {
729 let input = b"msgid \"caf\xe9\"\nmsgstr \"\"\n";
730 let error = parse_po_bytes(input).expect_err("invalid utf8 should fail");
731
732 assert!(error.message().contains("not valid UTF-8"));
733 assert!(error.message().contains("byte 10"));
734 }
735
736 #[test]
737 fn parse_po_bytes_reports_declared_charset_before_utf8_error() {
738 let input = b"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n\nmsgid \"caf\xe9\"\nmsgstr \"\"\n";
739 let error = parse_po_bytes(input).expect_err("declared charset should fail first");
740
741 assert!(error.message().contains("unsupported PO charset"));
742 assert!(error.message().contains("ISO-8859-1"));
743 }
744
745 #[test]
746 fn declared_charset_reads_header_value_case_insensitively() {
747 let input = b"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; CHARSET=utf8\\n\"\n\n";
748
749 assert_eq!(declared_charset(input), Some("utf8"));
750 }
751
752 #[test]
753 fn ascii_case_search_matches_alpha_and_non_alpha_anchors() {
754 assert_eq!(
755 find_ascii_case(b"xxCONTENT-TYPE: text/plain", b"content-type:"),
756 Some(2)
757 );
758 assert_eq!(find_ascii_case(b"name=value", b"=VALUE"), Some(4));
759 assert_eq!(find_ascii_case(b"anything", b""), Some(0));
760 assert_eq!(find_ascii_case(b"anything", b"missing"), None);
761 }
762
763 #[test]
764 fn declared_charset_ignores_non_header_text() {
765 let input = b"msgid \"charset=ISO-8859-1\"\nmsgstr \"\"\n";
766
767 assert_eq!(declared_charset(input), None);
768 }
769
770 #[test]
771 fn rejects_unescaped_quote_sequences() {
772 let input = "msgid \"Some msgid with \\\"double\\\" quotes\"\nmsgstr \"\"\n\"Some msgstr with \"double\\\" quotes\"\n";
773 let error = parse_po(input).expect_err("invalid quote pattern should fail");
774
775 assert!(error.to_string().contains("unescaped"));
776 }
777}