#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Php,
Js,
Twig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decoded {
Literal(String),
Dynamic,
}
pub fn decode_token(lang: Lang, token: &str) -> Decoded {
let Some((quote, inner)) = split_quotes(token) else {
return Decoded::Literal(String::new());
};
match (lang, quote) {
(Lang::Php, '"') => {
if has_php_interpolation(inner) {
Decoded::Dynamic
} else {
Decoded::Literal(unescape_php_double(inner))
}
}
(Lang::Php, _) => Decoded::Literal(unescape_php_single(inner)),
(Lang::Js, '`') => {
if has_template_interpolation(inner) {
Decoded::Dynamic
} else {
Decoded::Literal(unescape_js(inner))
}
}
(Lang::Js, _) => Decoded::Literal(unescape_js(inner)),
(Lang::Twig, '"') => {
if inner.contains("#{") {
Decoded::Dynamic
} else {
Decoded::Literal(unescape_twig(inner))
}
}
(Lang::Twig, _) => Decoded::Literal(unescape_twig(inner)),
}
}
fn split_quotes(token: &str) -> Option<(char, &str)> {
let mut chars = token.chars();
let first = chars.next()?;
if !matches!(first, '\'' | '"' | '`') {
return None;
}
let inner = chars.as_str();
let inner = inner.strip_suffix(first).unwrap_or(inner);
Some((first, inner))
}
fn has_php_interpolation(inner: &str) -> bool {
let mut chars = inner.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
}
'$' => return true,
_ => {}
}
}
false
}
fn has_template_interpolation(inner: &str) -> bool {
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
}
'$' if chars.peek() == Some(&'{') => return true,
_ => {}
}
}
false
}
pub fn unescape_php_single(inner: &str) -> String {
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('\\') => {
out.push('\\');
chars.next();
}
Some('\'') => {
out.push('\'');
chars.next();
}
_ => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
pub fn unescape_php_double(inner: &str) -> String {
decode_escapes(inner, EscapeFlavor::PhpDouble)
}
pub fn unescape_js(inner: &str) -> String {
decode_escapes(inner, EscapeFlavor::Js)
}
pub fn unescape_twig(inner: &str) -> String {
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('\\') => {
out.push('\\');
chars.next();
}
Some('\'') => {
out.push('\'');
chars.next();
}
Some('"') => {
out.push('"');
chars.next();
}
_ => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
#[derive(Clone, Copy)]
enum EscapeFlavor {
PhpDouble,
Js,
}
fn decode_escapes(inner: &str, flavor: EscapeFlavor) -> String {
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('\'') => out.push('\''),
Some('`') => out.push('`'),
Some('$') => out.push('$'),
Some('0') => out.push('\0'),
Some('v') => out.push('\u{0b}'),
Some('f') => out.push('\u{0c}'),
Some('b') => out.push('\u{08}'),
Some('e') if matches!(flavor, EscapeFlavor::PhpDouble) => out.push('\u{1b}'),
Some(other) => match flavor {
EscapeFlavor::PhpDouble => {
out.push('\\');
out.push(other);
}
EscapeFlavor::Js => out.push(other),
},
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn php_single_quote_keeps_backslash_n_literal() {
assert_eq!(
decode_token(Lang::Php, "'a\\nb'"),
Decoded::Literal("a\\nb".to_string())
);
assert_eq!(
decode_token(Lang::Php, "'it\\'s'"),
Decoded::Literal("it's".to_string())
);
}
#[test]
fn php_double_quote_decodes_escapes() {
assert_eq!(
decode_token(Lang::Php, "\"a\\nb\""),
Decoded::Literal("a\nb".to_string())
);
}
#[test]
fn php_double_quote_with_variable_is_dynamic() {
assert_eq!(decode_token(Lang::Php, "\"role_$x\""), Decoded::Dynamic);
assert_eq!(
decode_token(Lang::Php, "\"user_{$role}\""),
Decoded::Dynamic
);
assert_eq!(
decode_token(Lang::Php, "\"price_\\$5\""),
Decoded::Literal("price_$5".to_string())
);
}
#[test]
fn js_plain_and_template_literals() {
assert_eq!(
decode_token(Lang::Js, "'plain'"),
Decoded::Literal("plain".to_string())
);
assert_eq!(
decode_token(Lang::Js, "`plain`"),
Decoded::Literal("plain".to_string())
);
assert_eq!(
decode_token(Lang::Js, "\"a\\tb\""),
Decoded::Literal("a\tb".to_string())
);
}
#[test]
fn js_template_with_interpolation_is_dynamic() {
assert_eq!(
decode_token(Lang::Js, "`cf_subtype_${x}`"),
Decoded::Dynamic
);
assert_eq!(
decode_token(Lang::Js, "`cost_\\${x}`"),
Decoded::Literal("cost_${x}".to_string())
);
}
#[test]
fn twig_string_literal() {
assert_eq!(
decode_token(Lang::Twig, "'key'"),
Decoded::Literal("key".to_string())
);
assert_eq!(
decode_token(Lang::Twig, "\"key\""),
Decoded::Literal("key".to_string())
);
assert_eq!(decode_token(Lang::Twig, "\"x#{y}\""), Decoded::Dynamic);
}
#[test]
fn canonical_php_matches_po_decoded() {
let php = decode_token(Lang::Php, "\"a\\nb\"");
let po_side = "a\nb".to_string(); assert_eq!(php, Decoded::Literal(po_side));
}
}