Skip to main content

ferrocat_po/
parse.rs

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
111/// Parses PO content into the owned [`PoFile`] representation.
112///
113/// LF, CRLF, and bare CR line endings are accepted, and the UTF-8 BOM is
114/// ignored when present.
115///
116/// # Errors
117///
118/// Returns [`ParseError`] when the input is not valid PO syntax.
119pub 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
145/// Parses UTF-8 PO bytes into the owned [`PoFile`] representation.
146///
147/// This is the byte-oriented companion to [`parse_po`]. It rejects declared
148/// non-UTF-8 PO charsets before decoding, validates the input bytes as UTF-8,
149/// then delegates to [`parse_po`] for syntax parsing.
150///
151/// # Errors
152///
153/// Returns [`ParseError`] when the PO header declares an unsupported non-UTF-8
154/// charset, when the input bytes are not valid UTF-8, or when the decoded input
155/// is not valid PO syntax.
156pub 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}