From 19619ad2ccd85d9caba26a554aca6bda91874391 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:43:09 +0800 Subject: [PATCH] fix(js): decode FormCalc Unicode string escapes and preserve chained unary not/! (#6122) --- crates/js/src/formcalc/lexer.rs | 167 +++++++++++++++++++++++-------- crates/js/src/formcalc/parser.rs | 33 +++++- 2 files changed, 152 insertions(+), 48 deletions(-) diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index 46af19f761..9d01abf331 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -49,52 +49,12 @@ pub(super) fn lex(src: &str) -> Result, String> { } } '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); + let tok_line = line; + let s = lex_string(&chars, &mut i, &mut line)?; + out.push(Token { tok: Tok::Str(s), line: tok_line }); } c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + let n = lex_number(&chars, &mut i, line)?; out.push(Token { tok: Tok::Num(n), line }); } '!' => { @@ -127,3 +87,122 @@ pub(super) fn lex(src: &str) -> Result, String> { out.push(Token { tok: Tok::Eof, line }); Ok(out) } + +fn hex_u32(digits: &[char]) -> Option { + let mut v = 0u32; + for &c in digits { + v = (v << 4) | c.to_digit(16)?; + } + Some(v) +} + +/// FormCalc string escape (`\uXXXX` or `\UXXXXXXXX`, XFA 3.3 part 8 p. 1054); any other `\` stays literal. +fn unicode_escape(chars: &[char], at: usize) -> Option<(char, usize)> { + if chars.get(at) != Some(&'\\') { + return None; + } + match chars.get(at + 1)? { + 'u' => { + let hi = hex_u32(chars.get(at + 2..at + 6)?)?; + if (0xD800..=0xDBFF).contains(&hi) + && chars.get(at + 6..at + 8) == Some(&['\\', 'u']) + && let Some(lo) = chars.get(at + 8..at + 12).and_then(hex_u32).filter(|lo| (0xDC00..=0xDFFF).contains(lo)) + { + let scalar = 0x1_0000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + return Some((char::from_u32(scalar)?, 12)); + } + Some((char::from_u32(hi)?, 6)) + } + 'U' => { + let code = hex_u32(chars.get(at + 2..at + 10)?)?; + Some((char::from_u32(code)?, 10)) + } + _ => None, + } +} + +fn lex_string(chars: &[char], i: &mut usize, line: &mut usize) -> Result { + let mut s = String::new(); + *i += 1; + loop { + match chars.get(*i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(*i + 1) == Some(&'"') => { + s.push('"'); + *i += 2; + } + Some('"') => { + *i += 1; + return Ok(s); + } + Some(&ch) => { + if let Some((decoded, step)) = unicode_escape(chars, *i) { + s.push(decoded); + *i += step; + } else { + if ch == '\n' { + *line += 1; + } + s.push(ch); + *i += 1; + } + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } +} + +/// Whether `c` may start a FormCalc identifier. +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' || c == '$' +} + +/// End index of a numeric exponent starting at `at` (`e`/`E`, optional sign, digits), or `None` +/// if no valid exponent starts there or if identifier characters immediately follow the digits. +fn exponent_end(chars: &[char], at: usize) -> Option { + if !matches!(chars.get(at), Some('e' | 'E')) { + return None; + } + let mut j = at + 1; + if matches!(chars.get(j), Some('+' | '-')) { + j += 1; + } + if !chars.get(j).is_some_and(char::is_ascii_digit) { + return None; + } + while chars.get(j).is_some_and(char::is_ascii_digit) { + j += 1; + } + if chars.get(j).is_some_and(|&c| c.is_alphanumeric() || c == '_' || c == '$') { + return None; + } + Some(j) +} + +/// Scan a FormCalc number literal starting at `*i`, stopping before `.member` or `..` while +/// rejecting malformed multi-dot literals such as `1.2.3`. +fn lex_number(chars: &[char], i: &mut usize, line: usize) -> Result { + let start = *i; + let mut seen_dot = false; + while *i < chars.len() { + let next = chars.get(*i + 1).copied(); + let next_is_member = next.is_some_and(is_ident_start) && exponent_end(chars, *i + 1).is_none(); + if chars[*i].is_ascii_digit() { + *i += 1; + } else if chars[*i] == '.' && !seen_dot && next != Some('.') && !next_is_member { + seen_dot = true; + *i += 1; + } else if chars[*i] == '.' && seen_dot && next.is_some_and(|c| c.is_ascii_digit()) { + return Err(format!("line {line}: bad number")); + } else { + break; + } + } + if let Some(end) = exponent_end(chars, *i) { + *i = end; + } + let text: String = chars[start..*i].iter().collect(); + text.parse().map_err(|_| format!("line {line}: bad number {text}")) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index fceedadf5f..871d3874de 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -341,10 +341,7 @@ impl Parser { Ok(Expr::Neg(Box::new(self.unary()?))) } else if self.eat_op("+") { self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } + } else if self.eat_kw("not") || self.eat_kw("!") { Ok(Expr::Not(Box::new(self.unary()?))) } else { self.postfix() @@ -449,3 +446,31 @@ pub(super) fn parse(src: &str) -> Result, String> { t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unary_not_does_not_swallow_following_bang() { + assert_eq!(parse("not !1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + assert_eq!(parse("not !flag"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Ident("flag".into()))))))])); + assert_eq!(parse("!!1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + } + + #[test] + fn string_literals_decode_xfa_unicode_escapes_and_keep_other_backslashes() { + assert_eq!(parse(r#""\u0041\u20ac\U00000042\U0001F600""#), Ok(vec![Stmt::Expr(Expr::Str("A\u{20ac}B\u{1F600}".into()))])); + assert_eq!(parse(r#""\uD83D\uDE00""#), Ok(vec![Stmt::Expr(Expr::Str("\u{1F600}".into()))])); + assert_eq!(parse(r#""C:\temp\new\u12""#), Ok(vec![Stmt::Expr(Expr::Str(r"C:\temp\new\u12".into()))])); + } + + #[test] + fn number_literals_stop_before_second_dot_for_member_access() { + assert_eq!(parse("1.5.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.5)), "rawValue".into()))])); + assert_eq!(parse("1.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "rawValue".into()))])); + assert_eq!(parse("1.e2foo"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "e2foo".into()))])); + assert_eq!(parse("1.e2"), Ok(vec![Stmt::Expr(Expr::Num(100.0))])); + assert!(parse("1.2.3").is_err(), "a second decimal point followed by digits is a bad number"); + } +}