Skip to main content

ferrocat_po/
text.rs

1use std::borrow::Cow;
2
3use crate::ParseError;
4use crate::scan::{find_byte, find_escapable_byte, find_quoted_bounds, has_byte};
5use crate::utf8::{input_slice_as_str, string_from_utf8};
6
7/// Escapes a PO string literal payload.
8#[must_use]
9pub fn escape_string(input: &str) -> String {
10    let bytes = input.as_bytes();
11    let Some(first_escape) = find_escapable_byte(bytes) else {
12        return input.to_owned();
13    };
14
15    let mut out = String::with_capacity(input.len() + 8);
16    out.push_str(&input[..first_escape]);
17    escape_string_from(&mut out, input, bytes, first_escape);
18
19    out
20}
21
22pub fn escape_string_into(out: &mut String, input: &str) {
23    let bytes = input.as_bytes();
24    let Some(first_escape) = find_escapable_byte(bytes) else {
25        out.push_str(input);
26        return;
27    };
28
29    escape_string_into_known(out, input, first_escape);
30}
31
32pub fn escape_string_into_with_first_escape(
33    out: &mut String,
34    input: &str,
35    first_escape: Option<usize>,
36) {
37    let Some(first_escape) = first_escape else {
38        out.push_str(input);
39        return;
40    };
41
42    escape_string_into_known(out, input, first_escape);
43}
44
45/// Unescapes a PO string literal payload.
46///
47/// # Errors
48///
49/// Returns [`ParseError`] when the escape sequence is malformed.
50pub fn unescape_string(input: &str) -> Result<String, ParseError> {
51    if !has_byte(b'\\', input.as_bytes()) {
52        return Ok(input.to_owned());
53    }
54
55    unescape_string_known(input)
56}
57
58/// Unescapes a PO string literal payload that is already known to contain at
59/// least one backslash, skipping the redundant lookup scan.
60///
61/// # Errors
62///
63/// Returns [`ParseError`] when the escape sequence is malformed.
64pub(crate) fn unescape_string_known(input: &str) -> Result<String, ParseError> {
65    let bytes = input.as_bytes();
66    let mut out = Vec::with_capacity(input.len());
67    let mut index = 0;
68
69    while index < bytes.len() {
70        let next_escape = if let Some(relative) = find_byte(b'\\', &bytes[index..]) {
71            index + relative
72        } else {
73            out.extend_from_slice(&bytes[index..]);
74            break;
75        };
76
77        out.extend_from_slice(&bytes[index..next_escape]);
78        index = next_escape + 1;
79        if index >= bytes.len() {
80            return Err(ParseError::new("unterminated escape sequence"));
81        }
82
83        let escaped = bytes[index];
84        match escaped {
85            b'a' => out.push(b'\x07'),
86            b'b' => out.push(b'\x08'),
87            b't' => out.push(b'\t'),
88            b'n' => out.push(b'\n'),
89            b'v' => out.push(b'\x0b'),
90            b'f' => out.push(b'\x0c'),
91            b'r' => out.push(b'\r'),
92            b'\'' => out.push(b'\''),
93            b'"' => out.push(b'"'),
94            b'\\' => out.push(b'\\'),
95            b'?' => out.push(b'?'),
96            b'0'..=b'7' => {
97                let mut value = u32::from(escaped - b'0');
98                let mut consumed = 1;
99                while consumed < 3 && index + consumed < bytes.len() {
100                    let next = bytes[index + consumed];
101                    if !(b'0'..=b'7').contains(&next) {
102                        break;
103                    }
104                    value = (value * 8) + u32::from(next - b'0');
105                    consumed += 1;
106                }
107                match char::from_u32(value) {
108                    Some(ch) => push_char_bytes(&mut out, ch),
109                    None => return Err(ParseError::new("invalid octal escape value")),
110                }
111                index += consumed - 1;
112            }
113            b'x' => {
114                if index + 2 >= bytes.len() {
115                    return Err(ParseError::new("incomplete hex escape"));
116                }
117                let hi = decode_hex(bytes[index + 1])?;
118                let lo = decode_hex(bytes[index + 2])?;
119                let value = u32::from((hi << 4) | lo);
120                match char::from_u32(value) {
121                    Some(ch) => push_char_bytes(&mut out, ch),
122                    None => return Err(ParseError::new("invalid hex escape value")),
123                }
124                index += 2;
125            }
126            other => out.push(other),
127        }
128
129        index += 1;
130    }
131
132    Ok(string_from_utf8(out))
133}
134
135/// Extracts and unescapes the first quoted PO string from `line`, borrowing
136/// from the input when no escapes are present.
137///
138/// # Errors
139///
140/// Returns [`ParseError`] when the quoted content is malformed.
141pub fn extract_quoted_cow(line: &str) -> Result<Cow<'_, str>, ParseError> {
142    extract_quoted_bytes_cow(line.as_bytes())
143}
144
145pub fn extract_quoted_bytes_cow(line: &[u8]) -> Result<Cow<'_, str>, ParseError> {
146    let Some((start, end)) = find_quoted_bounds(line) else {
147        return Ok(Cow::Borrowed(""));
148    };
149
150    let raw = &line[start..end];
151    if !validate_quoted_content(raw)? {
152        return Ok(Cow::Borrowed(bytes_to_str(raw)));
153    }
154
155    Ok(Cow::Owned(unescape_string_known(bytes_to_str(raw))?))
156}
157
158/// Extracts and unescapes the first quoted PO string from `line`.
159///
160/// # Errors
161///
162/// Returns [`ParseError`] when the quoted content is malformed.
163pub fn extract_quoted(line: &str) -> Result<String, ParseError> {
164    Ok(extract_quoted_bytes_cow(line.as_bytes())?.into_owned())
165}
166
167/// Visits each reference token in a `#:` comment line, borrowing from `input`
168/// where possible and allocating nothing in the common single-reference case.
169///
170/// The parsers push tokens straight into their item buffers through this entry
171/// point, avoiding the throwaway `Vec` that collecting first would require for
172/// every reference line.
173pub fn for_each_reference_token<'a>(input: &'a str, mut visit: impl FnMut(Cow<'a, str>)) {
174    let trimmed = input.trim();
175    if trimmed.is_empty() {
176        visit(Cow::Borrowed(""));
177        return;
178    }
179
180    // Fast path for the overwhelmingly common single-reference line (e.g.
181    // `src/app.rs:42`). A run of graphic ASCII bytes cannot contain a
182    // whitespace split point or a multi-byte directional isolate, so it is one
183    // token by definition. Emitting it directly skips the char-by-char scan and
184    // the `parts` buffer the slow path needs to validate multi-token splits.
185    if trimmed.bytes().all(|byte| byte.is_ascii_graphic()) {
186        visit(Cow::Borrowed(trimmed));
187        return;
188    }
189
190    let mut parts = Vec::new();
191    let mut start = None;
192    let mut isolate_depth = 0usize;
193
194    for (index, ch) in trimmed.char_indices() {
195        match ch {
196            '\u{2068}' => {
197                if start.is_none() {
198                    start = Some(index);
199                }
200                isolate_depth += 1;
201            }
202            '\u{2069}' => {
203                if start.is_none() {
204                    start = Some(index);
205                }
206                isolate_depth = isolate_depth.saturating_sub(1);
207            }
208            _ if ch.is_whitespace() && isolate_depth == 0 => {
209                if let Some(segment_start) = start.take()
210                    && segment_start < index
211                {
212                    parts.push(normalize_reference_token(&trimmed[segment_start..index]));
213                }
214            }
215            _ => {
216                if start.is_none() {
217                    start = Some(index);
218                }
219            }
220        }
221    }
222
223    if let Some(segment_start) = start
224        && segment_start < trimmed.len()
225    {
226        parts.push(normalize_reference_token(&trimmed[segment_start..]));
227    }
228
229    if parts.len() == 1 {
230        visit(normalize_reference_token(trimmed));
231        return;
232    }
233
234    if parts.iter().all(|part| part.contains(':')) {
235        for part in parts {
236            visit(part);
237        }
238        return;
239    }
240
241    visit(Cow::Borrowed(trimmed));
242}
243
244/// Validates the content between PO quotes and reports whether it contains any
245/// backslash, so callers can skip a redundant escape-lookup scan.
246///
247/// Returns `Ok(true)` when an unescape pass is required, `Ok(false)` when the
248/// content can be borrowed verbatim.
249///
250/// # Errors
251///
252/// Returns [`ParseError`] when an unescaped quote is present.
253pub fn validate_quoted_content(raw: &[u8]) -> Result<bool, ParseError> {
254    let mut trailing_backslashes = 0usize;
255    let mut saw_backslash = false;
256
257    for &byte in raw {
258        match byte {
259            b'\\' => {
260                trailing_backslashes += 1;
261                saw_backslash = true;
262            }
263            b'"' if has_even_trailing_backslashes(trailing_backslashes) => {
264                return Err(ParseError::new("unescaped quote in string literal"));
265            }
266            _ => trailing_backslashes = 0,
267        }
268    }
269
270    Ok(saw_backslash)
271}
272
273fn has_even_trailing_backslashes(count: usize) -> bool {
274    count.is_multiple_of(2)
275}
276
277fn escape_string_from(out: &mut String, input: &str, bytes: &[u8], first_escape: usize) {
278    let mut start = first_escape;
279
280    loop {
281        push_escape(out, bytes[start]);
282        let next_index = start + 1;
283        let Some(relative) = find_escapable_byte(&bytes[next_index..]) else {
284            out.push_str(&input[next_index..]);
285            break;
286        };
287
288        let absolute = next_index + relative;
289        out.push_str(&input[next_index..absolute]);
290        start = absolute;
291    }
292}
293
294#[inline]
295fn escape_string_into_known(out: &mut String, input: &str, first_escape: usize) {
296    let bytes = input.as_bytes();
297    out.push_str(&input[..first_escape]);
298    escape_string_from(out, input, bytes, first_escape);
299}
300
301fn push_escape(out: &mut String, byte: u8) {
302    out.push('\\');
303    out.push(match byte {
304        b'\x07' => 'a',
305        b'\x08' => 'b',
306        b'\t' => 't',
307        b'\n' => 'n',
308        b'\x0b' => 'v',
309        b'\x0c' => 'f',
310        b'\r' => 'r',
311        b'"' => '"',
312        b'\\' => '\\',
313        _ => unreachable!("unexpected escape byte"),
314    });
315}
316
317fn decode_hex(byte: u8) -> Result<u8, ParseError> {
318    match byte {
319        b'0'..=b'9' => Ok(byte - b'0'),
320        b'a'..=b'f' => Ok(byte - b'a' + 10),
321        b'A'..=b'F' => Ok(byte - b'A' + 10),
322        _ => Err(ParseError::new("invalid hex escape")),
323    }
324}
325
326fn push_char_bytes(out: &mut Vec<u8>, ch: char) {
327    if ch.is_ascii() {
328        out.push(ch as u8);
329        return;
330    }
331
332    let mut buf = [0u8; 4];
333    out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
334}
335
336fn bytes_to_str(bytes: &[u8]) -> &str {
337    input_slice_as_str(bytes)
338}
339
340fn normalize_reference_token(input: &str) -> Cow<'_, str> {
341    if !input.contains('\u{2068}') && !input.contains('\u{2069}') {
342        return Cow::Borrowed(input);
343    }
344
345    Cow::Owned(
346        input
347            .chars()
348            .filter(|ch| *ch != '\u{2068}' && *ch != '\u{2069}')
349            .collect(),
350    )
351}
352
353#[cfg(test)]
354mod tests {
355    use std::borrow::Cow;
356
357    use super::{
358        escape_string, escape_string_into, escape_string_into_with_first_escape, extract_quoted,
359        extract_quoted_bytes_cow, extract_quoted_cow, for_each_reference_token, unescape_string,
360        validate_quoted_content,
361    };
362
363    fn split_reference_comment(input: &str) -> Vec<Cow<'_, str>> {
364        let mut parts = Vec::new();
365        for_each_reference_token(input, |token| parts.push(token));
366        parts
367    }
368
369    #[test]
370    fn escapes_special_characters() {
371        assert_eq!(escape_string("Say \"Hi\""), "Say \\\"Hi\\\"");
372        assert_eq!(escape_string("a\tb"), "a\\tb");
373    }
374
375    #[test]
376    fn escapes_all_po_control_sequences_and_plain_strings() {
377        assert_eq!(escape_string("plain"), "plain");
378        assert_eq!(
379            escape_string("\u{0007}\u{0008}\u{000b}\u{000c}\r\\"),
380            "\\a\\b\\v\\f\\r\\\\"
381        );
382    }
383
384    #[test]
385    fn unescapes_c_sequences() {
386        assert_eq!(
387            unescape_string("\\a\\b\\t\\n\\v\\f\\r\\'\\\"\\\\\\?").as_deref(),
388            Ok("\u{0007}\u{0008}\t\n\u{000b}\u{000c}\r'\"\\?")
389        );
390    }
391
392    #[test]
393    fn unescapes_plain_unicode_and_uppercase_hex_sequences() {
394        assert_eq!(unescape_string("plain").as_deref(), Ok("plain"));
395        assert_eq!(unescape_string("\\351\\x41").as_deref(), Ok("\u{00e9}A"));
396    }
397
398    #[test]
399    fn extracts_and_unescapes_quoted_text() {
400        assert_eq!(
401            extract_quoted(
402                "msgid \"The name field must not contain characters like \\\" or \\\\\""
403            )
404            .as_deref(),
405            Ok("The name field must not contain characters like \" or \\")
406        );
407    }
408
409    #[test]
410    fn borrows_simple_quoted_text_without_escape() {
411        assert_eq!(
412            extract_quoted_cow("msgid \"plain text\""),
413            Ok(Cow::Borrowed("plain text"))
414        );
415    }
416
417    #[test]
418    fn appends_escaped_text_into_existing_buffer() {
419        let mut out = String::from("prefix:");
420        escape_string_into(&mut out, "Say \"Hi\"\n");
421        assert_eq!(out, "prefix:Say \\\"Hi\\\"\\n");
422    }
423
424    #[test]
425    fn appends_escaped_text_into_existing_buffer_with_known_escape() {
426        let mut out = String::from("prefix:");
427        escape_string_into_with_first_escape(&mut out, "Say \"Hi\"\n", Some(4));
428        assert_eq!(out, "prefix:Say \\\"Hi\\\"\\n");
429    }
430
431    #[test]
432    fn appends_plain_text_when_no_escape_index_is_known() {
433        let mut out = String::from("prefix:");
434        escape_string_into_with_first_escape(&mut out, "plain", None);
435        assert_eq!(out, "prefix:plain");
436
437        escape_string_into(&mut out, "-more");
438        assert_eq!(out, "prefix:plain-more");
439    }
440
441    #[test]
442    fn extracts_quoted_text_from_bytes() {
443        assert_eq!(
444            extract_quoted_bytes_cow(br#"msgid "byte path""#),
445            Ok(Cow::Borrowed("byte path"))
446        );
447    }
448
449    #[test]
450    fn extracts_owned_quoted_text_when_unescaping_is_required() {
451        assert_eq!(
452            extract_quoted_bytes_cow(br#"msgid "line\nbreak""#),
453            Ok(Cow::Owned("line\nbreak".to_owned()))
454        );
455        assert_eq!(extract_quoted("msgid bare"), Ok(String::new()));
456    }
457
458    #[test]
459    fn splits_multiple_reference_tokens() {
460        assert_eq!(
461            split_reference_comment("src/app.js:1 src/lib.js:2"),
462            vec![Cow::Borrowed("src/app.js:1"), Cow::Borrowed("src/lib.js:2")]
463        );
464    }
465
466    #[test]
467    fn borrows_single_reference_token_via_fast_path() {
468        assert_eq!(
469            split_reference_comment("src/app.js:1"),
470            vec![Cow::Borrowed("src/app.js:1")]
471        );
472    }
473
474    #[test]
475    fn borrows_non_ascii_single_reference_token() {
476        // Non-ASCII bytes skip the graphic-ASCII fast path and exercise the
477        // slow path's single-token branch, which still borrows when there are
478        // no directional isolates to strip.
479        assert_eq!(
480            split_reference_comment("café.py:1"),
481            vec![Cow::Borrowed("café.py:1")]
482        );
483    }
484
485    #[test]
486    fn strips_leading_isolate_from_single_reference_token() {
487        // A token that opens with a closing isolate reaches the slow path with
488        // no active segment, covering the isolate handling before any content.
489        assert_eq!(
490            split_reference_comment("\u{2069}app.py:1"),
491            vec![Cow::<str>::Owned("app.py:1".to_owned())]
492        );
493    }
494
495    #[test]
496    fn preserves_standard_input_reference_lines() {
497        assert_eq!(
498            split_reference_comment("standard input:12 standard input:17"),
499            vec![Cow::Borrowed("standard input:12 standard input:17")]
500        );
501    }
502
503    #[test]
504    fn strips_isolates_when_splitting_reference_tokens() {
505        assert_eq!(
506            split_reference_comment("\u{2068}main 1.py\u{2069}:1 other.py:2"),
507            vec![
508                Cow::Owned("main 1.py:1".to_owned()),
509                Cow::Borrowed("other.py:2"),
510            ]
511        );
512    }
513
514    #[test]
515    fn keeps_non_reference_whitespace_groups_and_empty_input_stable() {
516        assert_eq!(
517            split_reference_comment("foo bar"),
518            vec![Cow::Borrowed("foo bar")]
519        );
520        assert_eq!(split_reference_comment("   "), vec![Cow::Borrowed("")]);
521    }
522
523    #[test]
524    fn rejects_unescaped_quote_in_string_literal() {
525        assert_eq!(
526            validate_quoted_content(br#"Some msgstr with "double\" quotes"#)
527                .expect_err("expected unescaped quote error")
528                .to_string(),
529            "unescaped quote in string literal"
530        );
531    }
532
533    #[test]
534    fn unescape_string_covers_octal_hex_and_error_paths() {
535        assert_eq!(unescape_string("\\101\\x42").as_deref(), Ok("AB"));
536        assert_eq!(
537            unescape_string("\\x4")
538                .expect_err("incomplete hex escape")
539                .to_string(),
540            "incomplete hex escape"
541        );
542        assert_eq!(
543            unescape_string("\\xZZ")
544                .expect_err("invalid hex escape")
545                .to_string(),
546            "invalid hex escape"
547        );
548        assert!(validate_quoted_content(br#"still safe\""#).is_ok());
549    }
550}