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#[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
45pub 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
58pub(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
135pub 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
158pub fn extract_quoted(line: &str) -> Result<String, ParseError> {
164 Ok(extract_quoted_bytes_cow(line.as_bytes())?.into_owned())
165}
166
167pub 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 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
244pub 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 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 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}