From ae336fc255e322a2a504cacebbdc79200119b665 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 13 Sep 2026 12:58:42 -0700 Subject: [PATCH] Lex the math expression parser's identifiers as Unicode identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names follow the `XID_Start XID_Continue*` rule of UAX #31, the same one Rust spells, so letters from every script name a variable and combining marks extend a name: a decomposed `é` is one name rather than the constant `e` beside a stray mark. Digits of any script, lone marks, invisible formatting characters like the right-to-left override, and symbols including emoji can no longer begin a name, which also leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them. Rust's special case admitting a leading underscore is deliberately not adopted. Reads the character classes from unicode-ident, the table crate the proc-macro stack already builds with, rather than hand-rolling the ranges. --- Cargo.lock | 1 + .../data_panel/data_panel_message_handler.rs | 19 +++++-- .../document/node_graph/node_properties.rs | 5 +- .../shapes/shape_utility.rs | 11 ++++ .../common_functionality/stroke_options.rs | 3 +- .../widgets/inputs/NumberInput.svelte | 14 ++++-- libraries/math-parser/Cargo.toml | 1 + libraries/math-parser/src/lexer.rs | 20 +++++--- libraries/math-parser/src/lib.rs | 45 +++++++++++------ node-graph/libraries/core-types/src/misc.rs | 50 ++++++++++++++++++- node-graph/nodes/text/src/lib.rs | 12 +++-- 11 files changed, 144 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07b29735a7..6977f6fc64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3415,6 +3415,7 @@ dependencies = [ "criterion", "num-complex", "thiserror 2.0.18", + "unicode-ident", ] [[package]] diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index b7fd333626..7cd64c76d8 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -10,6 +10,7 @@ use graph_craft::document::NodeId; use graphene_std::animation::RealTimeMode; use graphene_std::blending::BlendMode; use graphene_std::color::SRGBA8; +use graphene_std::core_types::misc::format_f64; use graphene_std::extract_xy::XY; use graphene_std::gradient::Gradient; use graphene_std::list::{Item, List, NodeIdPath}; @@ -859,20 +860,32 @@ macro_rules! impl_table_item_layout_for_number { } } impl_table_item_layout_for_number!( - f32 => "Number (f32)", u32 => "Number (u32)", i32 => "Number (i32)", u64 => "Number (u64)", i64 => "Number (i64)", ); +impl TableItemLayout for f32 { + fn type_name() -> &'static str { + "Number (f32)" + } + fn identifier(&self) -> String { + // Only infinity widens to f64 without gaining digits, so finite values keep the f32 spelling + if self.is_infinite() { format_f64(*self as f64) } else { format!("{self}") } + } + fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { + vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()] + } +} + // Denoised so 0.1 + 0.2 reads as 0.3 rather than 0.30000000000000004. We don't do this for f32 because it lacks precision to reliably distinguish between intentional digits and noise. impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number" } fn identifier(&self) -> String { - format!("{}", round_away_float_noise(*self)) + format_f64(round_away_float_noise(*self)) } fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()] @@ -964,7 +977,7 @@ impl TableItemLayout for Option { } fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { let text = match self { - Some(value) => format!("Some({})", round_away_float_noise(*value)), + Some(value) => format!("Some({})", format_f64(round_away_float_noise(*value))), None => "None".to_string(), }; diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 82a20b2f78..5708c57867 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -844,9 +844,8 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text string .split(&[',', ' ']) .filter(|x| !x.is_empty()) - .map(str::parse::) - .collect::, _>>() - .ok() + .map(graphene_std::core_types::misc::parse_f64) + .collect::>>() .map(TaggedValue::F64Array) }; diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index 0558adcab9..6c077983a8 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -16,6 +16,7 @@ use crate::messages::tool::utility_types::*; use glam::{DAffine2, DMat2, DVec2}; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; +use graphene_std::core_types::misc::format_f64; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::vector::algorithms::shapes::{arc_bezpath, regular_polygon_bezpath, star_polygon_bezpath}; use graphene_std::vector::click_target::ClickTargetType; @@ -558,6 +559,10 @@ pub fn wrap_to_tau(angle: f64) -> f64 { } pub fn format_rounded(value: f64, precision: usize) -> String { + if value.is_infinite() { + return format_f64(value); + } + // Denoised values within floating point noise of zero (including -0) display as unsigned zero, unless the precision is fine enough to display them let value = round_away_float_noise(value); let value = if value.abs() < f64::min(1e-12, 0.5 * 10_f64.powi(-(precision as i32))) { 0. } else { value }; @@ -638,6 +643,12 @@ mod tests { assert_eq!(format_rounded(45.00000000000001, 2), "45"); } + #[test] + fn format_rounded_spells_infinity_as_the_symbol() { + assert_eq!(format_rounded(f64::INFINITY, 2), "∞"); + assert_eq!(format_rounded(f64::NEG_INFINITY, 2), "-∞"); + } + #[test] fn format_rounded_shows_true_and_noise_zeros_plainly() { assert_eq!(format_rounded(0., 2), "0"); diff --git a/editor/src/messages/tool/common_functionality/stroke_options.rs b/editor/src/messages/tool/common_functionality/stroke_options.rs index fc4854ef76..985fd12d4e 100644 --- a/editor/src/messages/tool/common_functionality/stroke_options.rs +++ b/editor/src/messages/tool/common_functionality/stroke_options.rs @@ -4,6 +4,7 @@ use crate::messages::tool::common_functionality::color_selector::{DrawingToolSta use crate::messages::tool::common_functionality::graph_modification_utils; use graph_craft::document::value::TaggedValue; use graphene_std::choice_type::ChoiceTypeStatic; +use graphene_std::core_types::misc::parse_f64; use graphene_std::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; /// All non-color stroke-related options surfaced in the control bar popover. @@ -151,7 +152,7 @@ where .tooltip_label("Dash Pattern") .tooltip_description("Comma-separated dash and gap lengths.") .on_update(move |input: &TextInput| { - let parsed = input.value.split(&[',', ' ']).filter(|piece| !piece.is_empty()).map(str::parse::).collect::, _>>(); + let parsed = input.value.split(&[',', ' ']).filter(|piece| !piece.is_empty()).map(parse_f64).collect::>>(); parsed.map_or(Message::NoOp, |lengths| to_message(StrokeOptionsUpdate::DashLengths(lengths))) }) .on_commit(|_| DocumentMessage::StartTransaction.into()) diff --git a/frontend/src/components/widgets/inputs/NumberInput.svelte b/frontend/src/components/widgets/inputs/NumberInput.svelte index da29a82f5b..10091f443c 100644 --- a/frontend/src/components/widgets/inputs/NumberInput.svelte +++ b/frontend/src/components/widgets/inputs/NumberInput.svelte @@ -227,7 +227,13 @@ return `${sign}${unitlessDisplayValue.toFixed(decimalPlaces)}${unPluralize(unit, displayValue)}`; } - return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`; + return `${numberText(unitlessDisplayValue)}${unPluralize(unit, displayValue)}`; + } + + // Infinity is written as the math parser reads it, so a field showing it can be edited and committed unchanged + function numberText(number: number): string { + if (Math.abs(number) === Infinity) return number < 0 ? "-∞" : "∞"; + return `${number}`; } // Removes the trailing "s" from a unit if the quantity is 1. @@ -242,11 +248,11 @@ function onTextFocused() { // The number shown when editing the field, with floating point imprecision noise removed - const noFloatingImprecisionValue = value === undefined ? undefined : roundAwayFloatNoise(value); + const noFloatingImprecisionText = value === undefined ? undefined : numberText(roundAwayFloatNoise(value)); if (value === undefined) text = ""; - else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`; - else text = `${noFloatingImprecisionValue}${unPluralize(unit, value)}`; + else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionText}`; + else text = `${noFloatingImprecisionText}${unPluralize(unit, value)}`; editing = true; diff --git a/libraries/math-parser/Cargo.toml b/libraries/math-parser/Cargo.toml index fda6166caa..124fe89f4b 100644 --- a/libraries/math-parser/Cargo.toml +++ b/libraries/math-parser/Cargo.toml @@ -11,6 +11,7 @@ publish.workspace = true [dependencies] thiserror = "2.0" num-complex = "0.4" +unicode-ident = "1.0" chumsky = { version = "0.10", default-features = false, features = ["std"] } [dev-dependencies] diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index 7270ff28dc..2715a622b9 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -195,7 +195,10 @@ impl<'a> Lexer<'a> { preceding = rest.trim_end(); } - preceding.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.' || c == ')' || c == '∞') + preceding + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || unicode_ident::is_xid_continue(c) || c == '.' || c == ')' || c == '∞') } fn lex_number(&mut self) -> Option { @@ -235,14 +238,17 @@ impl<'a> Lexer<'a> { self.input[start_pos..self.pos].parse::().ok() } - /// Consumes identifier continuation characters: alphanumerics, underscores, and a decimal point sandwiched - /// between digits so that base-suffixed function names like `log3.25` lex as a single identifier. + /// Consumes identifier continuation characters: Unicode's `XID_Continue`, which covers letters, digits, + /// underscores, and the combining marks that complete a cluster like a decomposed `é`, plus a decimal point + /// sandwiched between digits so that base-suffixed function names like `log3.25` lex as a single identifier. fn consume_identifier_body(&mut self, first: char) -> &'a str { let start = self.pos; let mut previous = first; while let Some(c) = self.peek() { let dot_between_digits = c == '.' && previous.is_ascii_digit() && self.input[self.pos + 1..].chars().next().is_some_and(|next| next.is_ascii_digit()); - if !(c.is_alphanumeric() || c == '_' || dot_between_digits) { + // The middle dot and its Greek twin are identifier characters in Unicode, but they would pass for the `⋅` operator mid-name + let middle_dot = matches!(c as u32, 0xB7 | 0x387); + if !((unicode_ident::is_xid_continue(c) && !middle_dot) || dot_between_digits) { break; } previous = c; @@ -365,10 +371,12 @@ impl<'a> Lexer<'a> { If } else if let Some(lit) = Constant::from_name(ident) { Const(lit) - } else if ch.is_alphanumeric() { + } else if unicode_ident::is_xid_start(ch) { + // A name is a Unicode identifier, as in Rust, so any script's letters may spell one Ident(ident) } else { - // Punctuation never begins a name, which leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them + // Digits, combining marks, invisible formatting characters, and symbols never begin a name, which also + // leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them Error } } diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index a1497bfc9c..3ddc274e4d 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -59,7 +59,7 @@ mod tests { #[test] fn dot_led_function_suffixes_fail_to_parse() { // A `.`-led base suffix must stay an error, keeping dot-after-identifier free for possible future accessor syntax (the supported spelling is `log0.5`) - for input in ["log.5(8)", "log.5", "root.5(9)"] { + for input in ["log.5(8)", "log.5", "root.5(9)", "log_.5(8)"] { assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); } } @@ -78,20 +78,6 @@ mod tests { assert_eq!(result.unwrap().as_real(), Some(80.)); } - #[test] - fn underscore_dot_suffix_stays_implicit_multiplication() { - struct LogUnderscore; - impl context::ValueProvider for LogUnderscore { - fn get_value(&self, name: &str) -> Option { - (name == "log_").then(|| Value::from_f64(10.)) - } - } - - // `log_.5(8)` is not a suffixed function call: it reads as the variable `log_` times 0.5 times 8 - let result = ast::Node::try_parse_from_str("log_.5(8)").unwrap().eval(&EvalContext::new(LogUnderscore, context::NothingMap)); - assert_eq!(result.unwrap().as_real(), Some(40.)); - } - #[test] fn extremely_long_fraction_parses() { let input = format!("0.{}", "1".repeat(320)); @@ -439,6 +425,35 @@ mod tests { log_complex_suffixed_base: "log3(i)" => Complex::new(0., std::f64::consts::FRAC_PI_2 / 3f64.ln()), } + #[test] + fn names_are_unicode_identifiers() { + // Any script's letters begin a name, and combining marks extend one, so a decomposed `é` is a single name + let decomposed_e_acute = format!("e{}", char::from_u32(0x301).unwrap()); + for input in ["λ + 1", "あ", "א", "x_2", decomposed_e_acute.as_str()] { + assert!(ast::Node::try_parse_from_str(input).is_ok(), "expected `{input}` to parse"); + } + + // Symbols, emoji, digits of any script, lone marks, and invisible formatting characters cannot begin one, + // and neither can an underscore, whose leading position Rust allows by a special case that we do not + let lone_acute_mark = char::from_u32(0x301).unwrap().to_string(); + let right_to_left_override = format!("{}foo", char::from_u32(0x202E).unwrap()); + let flag = format!("{}{}", char::from_u32(0x1F1FA).unwrap(), char::from_u32(0x1F1F8).unwrap()); + for input in ["👍", "2👍", "٣", "²", "_foo", lone_acute_mark.as_str(), right_to_left_override.as_str(), flag.as_str()] { + assert!(ast::Node::try_parse_from_str(input).is_err(), "expected `{input}` to be a parse error"); + } + + // Neither middle dot continues a name, so `a·b` is an error rather than one variable of that name + for middle_dot in [0xB7, 0x387] { + let input = format!("a{}b", char::from_u32(middle_dot).unwrap()); + assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error"); + } + + // A name ending in a combining mark is still an operand, so a spaced number after it doesn't silently multiply + for input in ["x 2".to_string(), format!("{decomposed_e_acute} 2")] { + assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error"); + } + } + #[test] fn sigils_do_not_begin_names() { // Punctuation never begins a name, so these stay available as future namespace prefixes diff --git a/node-graph/libraries/core-types/src/misc.rs b/node-graph/libraries/core-types/src/misc.rs index a83994d9cd..447e357d84 100644 --- a/node-graph/libraries/core-types/src/misc.rs +++ b/node-graph/libraries/core-types/src/misc.rs @@ -124,9 +124,30 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) - }) } +/// Parses a number from text, reading infinity as `∞` (optionally signed) as well as the `inf` and `infinity` spellings. +pub fn parse_f64(text: &str) -> Option { + let (negative, unsigned) = match text.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, text.strip_prefix('+').unwrap_or(text)), + }; + if unsigned == "∞" { + return Some(if negative { f64::NEG_INFINITY } else { f64::INFINITY }); + } + + text.parse().ok() +} + +/// Writes a number as text, spelling infinity `∞` as the math expression language does rather than Rust's `inf`. +pub fn format_f64(value: f64) -> String { + if value.is_infinite() { + return if value < 0. { "-∞" } else { "∞" }.to_string(); + } + value.to_string() +} + /// Parses a comma or space separated list of numbers, skipping any pieces that fail to parse. pub fn parse_f64_list(text: &str) -> Vec { - text.split([',', ' ']).filter(|piece| !piece.is_empty()).filter_map(|piece| piece.parse::().ok()).collect() + text.split([',', ' ']).filter(|piece| !piece.is_empty()).filter_map(parse_f64).collect() } /// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`Color`] using the `color` crate's CSS Color 4 parser. @@ -162,3 +183,30 @@ pub fn parse_css_color(input: &str) -> Option { let in_gamut = alpha <= 1. && ![red, green, blue, alpha].iter().any(|c| c.is_sign_negative() || !c.is_finite()); in_gamut.then(|| crate::Color::from_gamma_srgb_channels(red, green, blue, alpha)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_f64_reads_every_infinity_spelling() { + for text in ["∞", "+∞", "inf", "infinity"] { + assert_eq!(parse_f64(text), Some(f64::INFINITY), "`{text}`"); + } + for text in ["-∞", "-inf", "-infinity"] { + assert_eq!(parse_f64(text), Some(f64::NEG_INFINITY), "`{text}`"); + } + assert_eq!(parse_f64("-2.5"), Some(-2.5)); + assert_eq!(parse_f64("∞∞"), None); + } + + #[test] + fn format_f64_spells_infinity_as_the_symbol_and_round_trips() { + assert_eq!(format_f64(f64::INFINITY), "∞"); + assert_eq!(format_f64(f64::NEG_INFINITY), "-∞"); + assert_eq!(format_f64(2.5), "2.5"); + for value in [f64::INFINITY, f64::NEG_INFINITY, 2.5, -0.1] { + assert_eq!(parse_f64(&format_f64(value)), Some(value)); + } + } +} diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 15bbf58bfc..cf3d2b0c0e 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -10,6 +10,7 @@ use convert_case::{Boundary, Converter, pattern}; use core_types::graphene_hash::CacheHash; use core_types::list::{Item, List}; use core_types::math::float_noise::round_away_float_noise; +use core_types::misc::{format_f64, parse_f64}; use core_types::registry::types::{SeedValue, SignedInteger, TextArea}; use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl}; use dyn_any::DynAny; @@ -364,6 +365,9 @@ fn format_number( ) -> Item { let (number, attributes) = number.into_parts(); let number = round_away_float_noise(number); + if number.is_infinite() { + return Item::from_parts(format_f64(number), attributes); + } let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) = (*decimal_places.element(), *fixed_decimals.element(), *use_thousands_separator.element(), *start_at_10000.element()); let decimal_separator = decimal_separator.element().clone(); @@ -447,14 +451,14 @@ fn format_number( #[node_macro::node(category("Text"), name("String to Number"))] fn string_to_number( _: impl Ctx, - /// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, and scientific notation (e.g. "1e-3") is supported. + /// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, scientific notation (e.g. "1e-3") is supported, and infinity may be written "inf", "infinity", or "∞". string: Item, /// The value of the result if the string cannot be parsed as a valid number. fallback: Item, ) -> Item { let (string, attributes) = string.into_parts(); - Item::from_parts(string.trim().parse::().unwrap_or(*fallback.element()), attributes) + Item::from_parts(parse_f64(string.trim()).unwrap_or(*fallback.element()), attributes) } /// Parses a string like `"3, 4.5"` into a Vec2, using a comma and/or whitespace as separators. Falls back to the chosen value if the string is not a valid pair of numbers. @@ -474,9 +478,9 @@ fn string_to_vec2( .unwrap_or(trimmed); // Exactly two numbers, so a longer list is not quietly truncated into a pair - let mut numbers = unwrapped.split(|c: char| c == ',' || c.is_whitespace()).filter(|piece| !piece.is_empty()).map(str::parse::); + let mut numbers = unwrapped.split(|c: char| c == ',' || c.is_whitespace()).filter(|piece| !piece.is_empty()).map(parse_f64); let parsed = match (numbers.next(), numbers.next(), numbers.next()) { - (Some(Ok(x)), Some(Ok(y)), None) => DVec2::new(x, y), + (Some(Some(x)), Some(Some(y)), None) => DVec2::new(x, y), _ => *fallback.element(), };