patois-build 0.1.19

Gettext tooling for patois apps: extract strings from Rust, Swift, and Kotlin, compile .po catalogs, generate iOS and Android assets, and check translations for common mistakes.
Documentation
//! Prepares Rust source for `xgettext --language=C`, because `xgettext` has no Rust mode.
//!
//! The C tokenizer reads past the end of a lifetime or a raw string, which can swallow or splice
//! strings later in the file, and in later files, since they all go to one `xgettext` run. This
//! module blanks both constructs. Replacements keep the line structure, so a `// TRANSLATORS:` note
//! stays next to its string.

use crate::extract::read_unicode_escape;

pub fn sanitize_for_xgettext(src: &str) -> String {
	let chars: Vec<char> = src.chars().collect();
	let n = chars.len();
	let mut out = String::with_capacity(src.len());
	let mut i = 0;
	while i < n {
		let c = chars[i];
		if c == '/' && i + 1 < n && chars[i + 1] == '/' {
			while i < n && chars[i] != '\n' {
				out.push(chars[i]);
				i += 1;
			}
		} else if c == '/' && i + 1 < n && chars[i + 1] == '*' {
			out.push('/');
			out.push('*');
			i += 2;
			let mut depth = 1usize;
			while i < n && depth > 0 {
				if i + 1 < n && chars[i] == '/' && chars[i + 1] == '*' {
					out.push('/');
					out.push('*');
					i += 2;
					depth += 1;
				} else if i + 1 < n && chars[i] == '*' && chars[i + 1] == '/' {
					out.push('*');
					out.push('/');
					i += 2;
					depth -= 1;
				} else {
					out.push(chars[i]);
					i += 1;
				}
			}
		} else if c == '"' {
			out.push(c);
			i += 1;
			while i < n {
				let ch = chars[i];
				// C has no `\u{…}`, so xgettext would copy it into the msgid verbatim.
				if ch == '\\'
					&& chars.get(i + 1) == Some(&'u')
					&& let Some((decoded, next)) = read_unicode_escape(&chars, i + 1)
				{
					match decoded {
						'"' | '\\' => out.extend(['\\', decoded]),
						'\n' => out.push_str("\\n"),
						'\t' => out.push_str("\\t"),
						_ => out.push(decoded),
					}
					i = next;
					continue;
				}
				out.push(ch);
				if ch == '\\' && i + 1 < n {
					out.push(chars[i + 1]);
					i += 2;
					continue;
				}
				i += 1;
				if ch == '"' {
					break;
				}
			}
		} else if c == 'r' {
			if let Some(hashes) = raw_string_hash_count(&chars, i) {
				for _ in 0..(hashes + 2) {
					out.push(' ');
				}
				i += hashes + 2;
				blank_raw_string_body(&chars, &mut i, hashes, &mut out);
			} else {
				out.push(c);
				i += 1;
			}
		} else if c == '\'' {
			if let Some(end) = char_literal_end(&chars, i) {
				out.extend(&chars[i..=end]);
				i = end + 1;
			} else {
				out.push(' ');
				i += 1;
			}
		} else {
			out.push(c);
			i += 1;
		}
	}
	out
}

const fn raw_string_hash_count(chars: &[char], i: usize) -> Option<usize> {
	let n = chars.len();
	let mut j = i + 1;
	let mut hashes = 0usize;
	while j < n && chars[j] == '#' {
		hashes += 1;
		j += 1;
	}
	if j < n && chars[j] == '"' { Some(hashes) } else { None }
}

fn blank_raw_string_body(chars: &[char], i: &mut usize, hashes: usize, out: &mut String) {
	let n = chars.len();
	while *i < n {
		if chars[*i] == '"' {
			let mut k = *i + 1;
			let mut matched = 0usize;
			while k < n && matched < hashes && chars[k] == '#' {
				matched += 1;
				k += 1;
			}
			if matched == hashes {
				for _ in 0..=hashes {
					out.push(' ');
				}
				*i = k;
				return;
			}
		}
		if chars[*i] == '\n' {
			out.push('\n');
		} else {
			out.push(' ');
		}
		*i += 1;
	}
}

/// Returns the index of the closing quote if the `'` at `i` opens a char literal, or `None` if it's
/// a lifetime.
const fn char_literal_end(chars: &[char], i: usize) -> Option<usize> {
	let n = chars.len();
	let mut j = i + 1;
	if j >= n {
		return None;
	}
	if chars[j] == '\\' {
		j += 1;
		if j >= n {
			return None;
		}
		if chars[j] == 'u' && j + 1 < n && chars[j + 1] == '{' {
			let mut k = j + 2;
			while k < n && chars[k] != '}' {
				k += 1;
			}
			if k >= n {
				return None;
			}
			j = k + 1;
		} else {
			j += 1;
		}
	} else {
		j += 1;
	}
	if j < n && chars[j] == '\'' { Some(j) } else { None }
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn preserves_normal_strings_and_comments() {
		let src = "// TRANSLATORS: hi\nlet x = t(\"hello 'world'\");\n";
		assert_eq!(sanitize_for_xgettext(src), src);
	}

	#[test]
	fn neutralizes_lifetimes_without_changing_length_or_lines() {
		let src = "fn f<'a>(s: &'a str) -> &'a str { s }\n";
		let out = sanitize_for_xgettext(src);
		assert_eq!(out.chars().count(), src.chars().count());
		assert_eq!(out.lines().count(), src.lines().count());
		assert!(!out.contains('\''));
	}

	#[test]
	fn preserves_char_literals() {
		let src = "let c = '{'; let d = '\\n'; let e = '\\'';\n";
		let out = sanitize_for_xgettext(src);
		assert!(out.contains("'{'"));
		assert!(out.contains("'\\n'"));
		assert!(out.contains("'\\''"));
	}

	#[test]
	fn decodes_unicode_escapes_in_strings() {
		let src = "t(\"{} on \\u{201c}{}\\u{201d}: \\u{22}\\u{5c}\");\nlet c = '\\u{2603}';\n";
		assert_eq!(sanitize_for_xgettext(src), "t(\"{} on \u{201c}{}\u{201d}: \\\"\\\\\");\nlet c = '\\u{2603}';\n");
	}

	#[test]
	fn blanks_raw_strings_but_keeps_line_count() {
		let src = "let xml = r#\"\n<a href=\"x\">it's</a>\n\"#;\nlet y = t(\"real\");\n";
		let out = sanitize_for_xgettext(src);
		assert_eq!(out.lines().count(), src.lines().count());
		assert!(out.contains("t(\"real\")"));
		assert!(!out.contains("href"));
	}
}