diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index ac69eb616..d0bb3be08 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -807,86 +807,139 @@ impl Lexer<'_> { } } - fn number(&mut self, mut start: usize, c: char) -> SyntaxKind { + fn number(&mut self, start: usize, first_c: char) -> SyntaxKind { // Handle alternative integer bases. - let mut base = 10; - if c == '0' { - if self.s.eat_if('b') { - base = 2; - } else if self.s.eat_if('o') { - base = 8; - } else if self.s.eat_if('x') { - base = 16; - } - if base != 10 { - start = self.s.cursor(); - } - } - - // Read the first part (integer or fractional depending on `first`). - self.s.eat_while(if base == 16 { - char::is_ascii_alphanumeric - } else { - char::is_ascii_digit - }); - - // Read the fractional part if not already done. - // Make sure not to confuse a range for the decimal separator. - if c != '.' - && !self.s.at("..") - && !self.s.scout(1).is_some_and(is_id_start) - && self.s.eat_if('.') - && base == 10 - { - self.s.eat_while(char::is_ascii_digit); - } - - // Read the exponent. - if !self.s.at("em") && self.s.eat_if(['e', 'E']) && base == 10 { - self.s.eat_if(['+', '-']); - self.s.eat_while(char::is_ascii_digit); - } - - // Read the suffix. - let suffix_start = self.s.cursor(); - if !self.s.eat_if('%') { - self.s.eat_while(char::is_ascii_alphanumeric); - } - - let number = self.s.get(start..suffix_start); - let suffix = self.s.from(suffix_start); - - let kind = if i64::from_str_radix(number, base).is_ok() { - SyntaxKind::Int - } else if base == 10 && number.parse::().is_ok() { - SyntaxKind::Float - } else { - return self.error(match base { - 2 => eco_format!("invalid binary number: 0b{}", number), - 8 => eco_format!("invalid octal number: 0o{}", number), - 16 => eco_format!("invalid hexadecimal number: 0x{}", number), - _ => eco_format!("invalid number: {}", number), - }); + let base = match first_c { + '0' if self.s.eat_if('b') => 2, + '0' if self.s.eat_if('o') => 8, + '0' if self.s.eat_if('x') => 16, + _ => 10, }; - if suffix.is_empty() { - return kind; + // Read the first part (integer or fractional depending on `first`). + if base == 16 { + self.s.eat_while(char::is_ascii_alphanumeric); + } else { + self.s.eat_while(char::is_ascii_digit); } - if !matches!( - suffix, - "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%" - ) { - return self.error(eco_format!("invalid number suffix: {}", suffix)); + // Maybe read a floating point number + let mut is_float = false; // `true` implies `base == 10` + if base == 10 { + // Read digits following a dot. Make sure not to confuse a spread + // operator or a method call for the decimal separator. + if first_c == '.' { + is_float = true; // We already ate the trailing digits above. + } else if !self.s.at("..") + && !self.s.scout(1).is_some_and(is_id_start) + && self.s.eat_if('.') + { + is_float = true; + self.s.eat_while(char::is_ascii_digit); + } + + // Read the exponent. + if !self.s.at("em") && self.s.eat_if(['e', 'E']) { + is_float = true; + self.s.eat_if(['+', '-']); + self.s.eat_while(char::is_ascii_digit); + } } - if base != 10 { - let kind = self.error(eco_format!("invalid base-{base} prefix")); - self.hint("numbers with a unit cannot have a base prefix"); - return kind; - } + // Prepare a number result, but don't return yet. + let number = self.s.from(start); + let number_result = if is_float && number.parse::().is_err() { + // The only invalid case should be when a float lacks digits after + // the exponent: e.g. `1.2e` or `2.3E-`. + Err(eco_format!("invalid floating point number: {number}")) + } else if base != 10 { + // The index `[2..]` skips the leading `0b`/`0o`/`0x`. + match i64::from_str_radix(&number[2..], base) { + Ok(int) => Ok(Some(int)), // Used for better errors below. + Err(_) => { + let name = match base { + 2 => "binary", + 8 => "octal", + 16 => "hexadecimal", + _ => unreachable!(), + }; + Err(eco_format!("invalid {name} number: {number}")) + } + } + } else { + Ok(None) + }; - SyntaxKind::Numeric + // Read the suffix. + let suffix = self.s.eat_while(|c: char| c.is_ascii_alphanumeric() || c == '%'); + let maybe_suffix_result = match suffix { + "" => None, + "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%" => Some(Ok(())), + _ => { + // Pass a hint for when the invalid suffix starts valid. + let valid_start_len = if suffix.starts_with('%') { + Some(1) + } else if matches!( + suffix.get(0..2), + Some("pt" | "mm" | "cm" | "in" | "em" | "fr",) + ) { + Some(2) + } else if matches!(suffix.get(0..3), Some("deg" | "rad")) { + Some(3) + } else { + None + }; + let maybe_hint = valid_start_len.map(|len| { + eco_format!("try adding a space after: `{}`", &suffix[0..len]) + }); + Some(Err(maybe_hint)) + } + }; + + // Return our number or write an error with helpful hints. + match (number_result, maybe_suffix_result) { + // Valid numbers :D + (Ok(None), Some(Ok(()))) => SyntaxKind::Numeric, + (Ok(_), None) if is_float => SyntaxKind::Float, + (Ok(_), None) => SyntaxKind::Int, + // Invalid numbers :( + (Ok(Some(decimal_value)), Some(suffix_res)) => { + let name = match base { + 2 => "binary", + 8 => "octal", + 16 => "hexadecimal", + _ => unreachable!(), + }; + let err = self.error(eco_format!("{name} numbers cannot have a suffix")); + if let Err(maybe_hint) = suffix_res { + self.hint(eco_format!("invalid number suffix: {suffix}")); + if let Some(h) = maybe_hint { + self.hint(h); + } + } else { + self.hint(eco_format!( + "try using a decimal number: {decimal_value}{suffix}" + )); + } + err + } + (Ok(None), Some(Err(maybe_hint))) => { + let err = self.error(eco_format!("invalid number suffix: {suffix}")); + if let Some(h) = maybe_hint { + self.hint(h); + } + err + } + (Err(message), Some(Err(maybe_hint))) => { + let err = self.error(message); + self.hint(eco_format!("invalid number suffix: {suffix}")); + if let Some(h) = maybe_hint { + self.hint(h); + } + err + } + (Err(message), None | Some(Ok(()))) => self.error(message), + } } fn string(&mut self) -> SyntaxKind { diff --git a/tests/ref/double-percent.png b/tests/ref/double-percent.png deleted file mode 100644 index 61a0d6143..000000000 Binary files a/tests/ref/double-percent.png and /dev/null differ diff --git a/tests/suite/foundations/float.typ b/tests/suite/foundations/float.typ index a18e9f09a..0579acaaf 100644 --- a/tests/suite/foundations/float.typ +++ b/tests/suite/foundations/float.typ @@ -107,11 +107,11 @@ #123.E // this is a field access, so is fine syntactically #0.e #1.E+020 -// Error: 2-10 invalid number: 123.456e +// Error: 2-10 invalid floating point number: 123.456e #123.456e -// Error: 2-11 invalid number: 123.456e+ +// Error: 2-11 invalid floating point number: 123.456e+ #123.456e+ -// Error: 2-6 invalid number: .1E- +// Error: 2-6 invalid floating point number: .1E- #.1E- -// Error: 2-4 invalid number: 0e +// Error: 2-4 invalid floating point number: 0e #0e diff --git a/tests/suite/layout/length.typ b/tests/suite/layout/length.typ index 3409614fd..1fd6452fb 100644 --- a/tests/suite/layout/length.typ +++ b/tests/suite/layout/length.typ @@ -75,11 +75,25 @@ // Hint: 2-24 or use `length.abs.inches()` instead to ignore its em component #(4.5em + 6in).inches() ---- issue-5519-length-base --- -// Error: 2-9 invalid base-2 prefix -// Hint: 2-9 numbers with a unit cannot have a base prefix +--- issue-5519-nondecimal-suffix --- +// Error: 2-9 binary numbers cannot have a suffix +// Hint: 2-9 try using a decimal number: 4pt #0b100pt +--- nondecimal-suffix-edge-cases --- +// Error: 2-7 octal numbers cannot have a suffix +// Hint: 2-7 try using a decimal number: 50% +#0o62% +// Error: 2-8 hexadecimal numbers cannot have a suffix +// Hint: 2-8 try using a decimal number: 2748% +#0xabc% +// Error: 2-9 invalid hexadecimal number: 0xabcem +#0xabcem +// Error: 2-11 binary numbers cannot have a suffix +// Hint: 2-11 invalid number suffix: dag +#0b0101dag + + --- number-syntax-edge-cases --- // Test numeric syntax edge cases with suffixes and which spans of text are // highlighted. Valid items are those not annotated with an error comment since @@ -92,17 +106,28 @@ #1.2E+0% #1.2e-0% #0.0e0deg -#5in% #0.% +// Error: 2-6 invalid number suffix: in% +// Hint: 2-6 try adding a space after: `in` +#5in% +// Error: 2-6 invalid number suffix: %in +// Hint: 2-6 try adding a space after: `%` +#5%in // Error: 2-8 invalid number suffix: hello #1hello // Error: 2-7 invalid number suffix: infr +// Hint: 2-7 try adding a space after: `in` #1infr -// Error: 2-5 invalid number: 2E +// Error: 2-5 invalid floating point number: 2E +// Hint: 2-5 invalid number suffix: M #2EM -// Error: 2-8 invalid number: .1E- +// Error: 2-8 invalid floating point number: .1E- #.1E-fr -// Error: 2-16 invalid number: 0.1E+ +// Error: 2-16 invalid floating point number: 0.1E+ +// Hint: 2-16 invalid number suffix: fr123e456 +// Hint: 2-16 try adding a space after: `fr` #0.1E+fr123e456 -// Error: 2-11 invalid number: .1e- +// Error: 2-11 invalid floating point number: .1e- +// Hint: 2-11 invalid number suffix: fr123 +// Hint: 2-11 try adding a space after: `fr` #.1e-fr123.456 diff --git a/tests/suite/layout/relative.typ b/tests/suite/layout/relative.typ index 5a5908920..13c12162e 100644 --- a/tests/suite/layout/relative.typ +++ b/tests/suite/layout/relative.typ @@ -6,10 +6,13 @@ #test((100% + 2pt - 2pt).length, 0pt) #test((56% + 2pt - 56%).ratio, 0%) ---- double-percent --- +--- double-percent-embedded --- // Test for two percent signs in a row. +// Error: 2-7 invalid number suffix: %% +// Hint: 2-7 try adding a space after: `%` #3.1%% ---- double-percent-error --- -// Error: 7-8 the character `%` is not valid in code +--- double-percent-parens --- +// Error: 3-8 invalid number suffix: %% +// Hint: 3-8 try adding a space after: `%` #(3.1%%)