diff --git a/Cargo.lock b/Cargo.lock index 28c3bd6..800e55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,7 +150,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -253,6 +253,17 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "calculator" +version = "0.1.0" +dependencies = [ + "meval", + "ruin-runtime", + "ruin_app", + "ruin_ui", + "tracing-subscriber", +] + [[package]] name = "cast" version = "0.3.0" @@ -606,6 +617,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -834,6 +851,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1165,6 +1193,16 @@ dependencies = [ "libc", ] +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom 1.2.4", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1226,6 +1264,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + [[package]] name = "nom" version = "8.0.0" @@ -1241,6 +1285,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1793,6 +1846,14 @@ dependencies = [ "syn", ] +[[package]] +name = "ruin-reactivity-parsing" +version = "0.1.0" +dependencies = [ + "ruin-runtime", + "ruin_reactivity", +] + [[package]] name = "ruin-runtime" version = "0.1.0" @@ -1831,6 +1892,7 @@ dependencies = [ name = "ruin_reactivity" version = "0.1.0" dependencies = [ + "hashbrown 0.17.0", "ruin-runtime", "tracing", "tracing-subscriber", @@ -2257,6 +2319,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -2266,12 +2340,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -2336,6 +2413,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/README.md b/README.md index e69de29..5560378 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,14 @@ +# RUIN - Rust Native Application Framework + +A layered architecture for fast, native, cross-platform applications. + +RUIN is a modern asynchronous runtime for interactive Rust applications on personal computers. It has a simple +threading and scheduling model, fast asynchronous I/O (io_uring on Linux, kqueue on macOS, and IOCP on windows), +and several libraries to enable building modern, reactive applications. + +## Runtime + +RUIN's core runtime is provided in [`ruin-runtime`](./lib/runtime/). + +The runtime is non-workstealing with no threadpool. When a RUIN application starts, it runs a simple event loop on the +main thread. \ No newline at end of file diff --git a/examples/calculator/Cargo.toml b/examples/calculator/Cargo.toml new file mode 100644 index 0000000..61b12ca --- /dev/null +++ b/examples/calculator/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "calculator" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "calculator" +path = "src/main.rs" + +[dependencies] +ruin_app = { path = "../../lib/ruin_app" } +ruin_ui = { path = "../../lib/ui" } +ruin-runtime = { path = "../../lib/runtime" } +meval = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/calculator/src/components.rs b/examples/calculator/src/components.rs new file mode 100644 index 0000000..e4d81ad --- /dev/null +++ b/examples/calculator/src/components.rs @@ -0,0 +1,375 @@ +use ruin_app::prelude::*; +use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily}; + +use crate::state::{CalcMode, CalcState}; + +// --------------------------------------------------------------------------- +// Display panel +// --------------------------------------------------------------------------- + +#[component] +pub fn Display(state: CalcState) -> impl IntoView { + let expression = state.expression.clone(); + let is_error = state.is_error.clone(); + let history = state.history.clone(); + + let result_color = use_memo({ + let is_error = is_error.clone(); + move || { + if is_error.get() { + Color::rgba(200, 30, 30, 245) + } else { + Color::rgba(20, 140, 20, 245) + } + } + }); + + let result_text = use_memo({ + let result = state.result.clone(); + move || { + let r = result.get(); + if r.is_empty() { " ".to_string() } else { format!("= {r}") } + } + }); + + view! { + block( + padding = 12.0, + gap = 8.0, + // min_height (content) sized to cover scroll_box + expression + result lines + // so the window auto-sizes correctly despite text min-size being 0. + // Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167 + min_height = 167.0, + align_self = AlignItems::Stretch, + align_items = AlignItems::End, + background = Color::rgba(255, 255, 255, 200), + border_radius = 12.0, + border = (1.0, Color::rgba(0, 0, 0, 60)), + shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer), + ) { + block(flex = 1.0, min_height = 0.0) {} + + // History (oldest at top, newest at bottom, scrollable) + scroll_box( + height = 100.0, + offset_y = state.history_scroll.clone(), + align_self = AlignItems::Stretch, + ) { + column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) { + history.with(|entries| { + entries + .iter() + .rev() + .map(|e| { + let expr = e.expression.clone(); + let res = e.result.clone(); + let s = state.clone(); + let res_for_press = res.clone(); + view! { + button( + align_self = AlignItems::End, + padding = 4.0, + background = Color::rgba(0, 0, 0, 0), + border_radius = 4.0, + on_press = move |_| s.use_history_result(res_for_press.clone()), + ) { + row(gap = 6.0, align_self = AlignItems::End) { + text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) { + expr.clone() + } + text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) { + format!("= {res}") + } + } + } + } + }) + .collect::>() + }) + } + } + + // Current expression + text( + size = 26.0, + font_family = TextFontFamily::Monospace, + color = Color::rgba(0, 0, 0, 245), + wrap = TextWrap::None, + weight = FontWeight::Normal, + pointer_events = false, + ) { + expression.get() + } + + // Result / error line + if is_error.get() { + view! { + text( + size = 16.0, + font_family = TextFontFamily::Monospace, + color = result_color.get(), + wrap = TextWrap::None, + weight = FontWeight::Normal, + pointer_events = false, + ) { + result_text.get() + } + } + } else { + view! { + block(min_height = 0.0) {} + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Mode toggle +// --------------------------------------------------------------------------- + +#[component] +pub fn ModeToggle(state: CalcState) -> impl IntoView { + let mode = state.mode.clone(); + + view! { + row(gap = 6.0, align_self = AlignItems::Stretch) { + toggle_button("Basic", mode.get() == CalcMode::Basic, { + let state = state.clone(); + move || state.toggle_mode() + }) + toggle_button("Scientific", mode.get() == CalcMode::Scientific, { + let state = state.clone(); + move || state.toggle_mode() + }) + } + } +} + +// --------------------------------------------------------------------------- +// Button helpers +// --------------------------------------------------------------------------- + +fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View { + let widget_ref = use_widget_ref::(); + let interaction = use_interaction_state(widget_ref.clone()); + + button() + .widget_ref(widget_ref) + .flex(1.0) + .min_width(54.0) + // min_height = content height for 20px bold text (20 * 1.2 = 24). + // This matches the actual rendered line height so window sizing is accurate + // and the text is visually centered by the symmetric 8px top/bottom padding. + .min_height(24.0) + .padding(Edges::symmetric(8.0, 12.0)) + .align_items(AlignItems::Center) + .background(interactive_background(bg, interaction)) + .border_radius(8.0) + .border((0.5, interactive_border(interaction))) + .shadow(interactive_shadow(interaction)) + .on_press(move |_| on_press()) + .children( + text() + .size(20.0) + .weight(FontWeight::Bold) + .selectable(false) + .pointer_events(false) + .children(label), + ) +} + +fn btn_row(buttons: Vec) -> View { + row().gap(6.0).children(buttons) +} + +fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View { + let widget_ref = use_widget_ref::(); + let interaction = use_interaction_state(widget_ref.clone()); + let base = if active { + Color::rgba(60, 100, 220, 180) + } else { + Color::rgba(255, 255, 255, 90) + }; + + button() + .widget_ref(widget_ref) + .flex(1.0) + .padding(8.0) + .min_height(17.0) + .align_items(AlignItems::Center) + .background(interactive_background(base, interaction)) + .border_radius(8.0) + .border((0.5, interactive_border(interaction))) + .shadow(interactive_shadow(interaction)) + .on_press(move |_| on_press()) + .children( + text() + .size(14.0) + .weight(FontWeight::Bold) + .selectable(false) + .pointer_events(false) + .children(label), + ) +} + +fn interactive_background(base: Color, interaction: InteractionState) -> Color { + if interaction.pressed { + mix(base, Color::BLACK, 0.22) + } else if interaction.hovered { + mix(base, Color::WHITE, 0.22) + } else { + base + } +} + +fn interactive_border(interaction: InteractionState) -> Color { + if interaction.pressed { + Color::rgba(0, 0, 0, 96) + } else if interaction.hovered { + Color::rgba(0, 0, 0, 72) + } else { + Color::rgba(0, 0, 0, 35) + } +} + +fn interactive_shadow(interaction: InteractionState) -> BoxShadow { + if interaction.pressed { + BoxShadow::new( + Point::new(0.0, 0.0), + 2.0, + 0.0, + Color::rgba(0, 0, 0, 36), + BoxShadowKind::Outer, + ) + } else if interaction.hovered { + BoxShadow::new( + Point::new(0.0, 3.0), + 14.0, + 0.0, + Color::rgba(0, 0, 0, 42), + BoxShadowKind::Outer, + ) + } else { + BoxShadow::new( + Point::new(0.0, 1.0), + 6.0, + 0.0, + Color::rgba(0, 0, 0, 18), + BoxShadowKind::Outer, + ) + } +} + +fn mix(base: Color, overlay: Color, amount: f32) -> Color { + let amount = amount.clamp(0.0, 1.0); + let blend = |from: u8, to: u8| -> u8 { + ((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8 + }; + + Color::rgba( + blend(base.r, overlay.r), + blend(base.g, overlay.g), + blend(base.b, overlay.b), + base.a, + ) +} + +// --------------------------------------------------------------------------- +// Keypad (basic) +// --------------------------------------------------------------------------- + +#[component] +pub fn Keypad(state: CalcState) -> impl IntoView { + let bg_num = Color::rgba(255, 255, 255, 90); + let bg_op = Color::rgba(210, 230, 255, 140); + let bg_eq = Color::rgba(160, 230, 160, 160); + let bg_fn = Color::rgba(255, 200, 180, 140); + + macro_rules! digit { + ($ch:expr) => {{ + let s = state.clone(); + let t = $ch.to_string(); + let label = t.clone(); + calc_button(label, bg_num, move || s.input(&t)) + }}; + } + macro_rules! op { + ($ch:expr) => {{ + let s = state.clone(); + let t = $ch.to_string(); + let label = t.clone(); + calc_button(label, bg_op, move || s.input(&t)) + }}; + } + + let clear_btn = { + let s = state.clone(); + calc_button("C".to_string(), bg_fn, move || s.clear()) + }; + let paren_btn = { + let s = state.clone(); + calc_button("( )".to_string(), bg_num, move || s.input_paren()) + }; + let pct_btn = { + let s = state.clone(); + calc_button("%".to_string(), bg_num, move || s.input("%")) + }; + let bs_btn = { + let s = state.clone(); + calc_button("⌫".to_string(), bg_fn, move || s.backspace()) + }; + let dot_btn = { + let s = state.clone(); + calc_button(".".to_string(), bg_num, move || s.input(".")) + }; + let eq_btn = { + let s = state.clone(); + calc_button("=".to_string(), bg_eq, move || s.evaluate()) + }; + + view! { + column(gap = 6.0, align_self = AlignItems::Stretch) { + btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn]) + btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')]) + btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')]) + btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')]) + btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')]) + } + } +} + +// --------------------------------------------------------------------------- +// Scientific panel +// --------------------------------------------------------------------------- + +#[component] +pub fn ScientificPanel(state: CalcState) -> impl IntoView { + let bg = Color::rgba(230, 215, 255, 140); + + macro_rules! sci { + ($label:expr, $insert:expr) => {{ + let s = state.clone(); + let ins: &'static str = $insert; + calc_button($label.to_string(), bg, move || s.input(ins)) + }}; + } + + let sq_btn = { + let s = state.clone(); + calc_button("x²".to_string(), bg, move || { + s.expression.update(|e| { + let wrapped = format!("({e})^2"); + *e = wrapped; + }); + }) + }; + + view! { + column(gap = 6.0, align_self = AlignItems::Stretch) { + btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")]) + btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn]) + btn_row(vec![sci!("xʸ", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")]) + } + } +} diff --git a/examples/calculator/src/main.rs b/examples/calculator/src/main.rs new file mode 100644 index 0000000..91746cb --- /dev/null +++ b/examples/calculator/src/main.rs @@ -0,0 +1,161 @@ +mod components; +mod state; + +use ruin_app::prelude::*; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +use components::{Display, Keypad, ModeToggle, ScientificPanel}; +use state::{CalcMode, CalcState}; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + init_tracing(); + + App::new() + .window( + Window::new() + .title("Calculator") + .app_id("dev.ruin.calculator") + .base_color(Color::rgba(255, 255, 255, 80)) + .resizable(false), + ) + .mount(view! { CalculatorApp() {} }) + .run() + .await +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new( + "info,ruin_app::resize=trace,ruin_app::interaction=trace,\ + ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\ + ruin_ui_platform_wayland::event_bridge=trace", + ) + }); + + let _ = tracing_subscriber::registry() + .with(fmt::layer().with_target(true).without_time()) + .with(filter) + .try_init(); +} + +#[component] +fn CalculatorApp() -> impl IntoView { + let state = CalcState::new(); + + // ---- Keyboard shortcuts (application-scoped) ---- + + for ch in "0123456789.+-*/%()".chars() { + let s = state.clone(); + let display = match ch { + '*' => "×", + '/' => "÷", + _ => "", + }; + let text = if display.is_empty() { + ch.to_string() + } else { + display.to_string() + }; + use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, { + let text = text.clone(); + move || s.input(&text) + }); + } + + // = key also evaluates + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear()); + } + + // ---- Layout ---- + + let mode = state.mode.clone(); + + let text_style = PartialTextStyle { + color: Some(Color::BLACK), + ..Default::default() + }; + + if mode.get() == CalcMode::Scientific { + view! { + column( + gap = 10.0, + padding = 12.0, + align_items = AlignItems::Stretch, + text_style = text_style, + ) { + Display(state = state.clone()) {} + ModeToggle(state = state.clone()) {} + ScientificPanel(state = state.clone()) {} + Keypad(state = state.clone()) {} + } + } + } else { + view! { + column( + gap = 10.0, + padding = 12.0, + align_items = AlignItems::Stretch, + text_style = text_style, + ) { + Display(state = state.clone()) {} + ModeToggle(state = state.clone()) {} + Keypad(state = state.clone()) {} + } + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + + #[component] + fn StateProbe(slot: Rc>>) -> impl IntoView { + let state = CalcState::new(); + *slot.borrow_mut() = Some(state); + block().children(()) + } + + #[test] + fn successful_evaluation_replaces_expression_and_preserves_history() { + let slot = Rc::new(RefCell::new(None::)); + let _ = ruin_app::__render_mountable_for_test(&view! { + StateProbe(slot = Rc::clone(&slot)) {} + }); + + let state = slot.borrow().clone().expect("state should be captured"); + let _ = state.expression.set("2+2".to_string()); + state.evaluate(); + + assert_eq!(state.expression.get(), "4"); + assert_eq!(state.result.get(), ""); + assert_eq!(state.is_error.get(), false); + assert_eq!(state.history.get().len(), 1); + assert_eq!(state.history.get()[0].expression, "2+2"); + assert_eq!(state.history.get()[0].result, "4"); + } +} diff --git a/examples/calculator/src/state.rs b/examples/calculator/src/state.rs new file mode 100644 index 0000000..febdd6c --- /dev/null +++ b/examples/calculator/src/state.rs @@ -0,0 +1,198 @@ +use ruin_app::prelude::*; + +type EvalResult = std::result::Result; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CalcMode { + #[default] + Basic, + Scientific, +} + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub expression: String, + pub result: String, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct CalcState { + /// The expression currently shown in the input display. + pub expression: Signal, + /// The result of the last evaluation (or error message). Empty until first `=`. + pub result: Signal, + /// Whether the last evaluation produced an error. + pub is_error: Signal, + /// Past evaluations, newest first. + pub history: Signal>, + pub mode: Signal, + /// When true, the next digit/operator input clears the expression first. + pub fresh: Signal, + /// Current scroll offset for the history scroll box. + pub history_scroll: Signal, +} + +impl CalcState { + pub fn new() -> Self { + Self { + expression: use_signal(|| String::new()), + result: use_signal(|| String::new()), + is_error: use_signal(|| false), + history: use_signal(|| Vec::new()), + mode: use_signal(|| CalcMode::Basic), + fresh: use_signal(|| false), + history_scroll: use_signal(|| 10_000.0_f32), + } + } + + /// Append `text` to the expression. If `fresh` is set (just evaluated), + /// clear the expression first so digits don't append to the old result. + pub fn input(&self, text: &str) { + if self.fresh.get() { + // Starting fresh after a result: if it's an operator continue + // from the result, otherwise start a new expression. + let is_operator = matches!(text, "+" | "-" | "×" | "÷" | "^" | "%"); + if !is_operator { + let _ = self.expression.set(String::new()); + } + let _ = self.fresh.set(false); + } + self.expression.update(|e| e.push_str(text)); + } + + /// Delete the last character (or close-paren group) from the expression. + pub fn backspace(&self) { + if self.fresh.get() { + let _ = self.fresh.set(false); + let _ = self.expression.set(String::new()); + return; + } + self.expression.update(|e| { + // Pop a full UTF-8 character + let new_len = e + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + e.truncate(new_len); + }); + } + + /// Clear the expression, result, and fresh flag. + pub fn clear(&self) { + let _ = self.expression.set(String::new()); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + let _ = self.fresh.set(false); + } + + /// Evaluate the current expression, update result, push to history. + pub fn evaluate(&self) { + let expr = self.expression.with(|e| e.clone()); + if expr.is_empty() { + return; + } + match evaluate(&expr) { + Ok(value) => { + let result_str = format_result(value); + let _ = self.expression.set(result_str.clone()); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + self.history.update(|h| { + h.insert( + 0, + HistoryEntry { + expression: expr.clone(), + result: result_str, + }, + ); + h.truncate(50); + }); + } + Err(msg) => { + let _ = self.result.set(msg); + let _ = self.is_error.set(true); + } + } + let _ = self.fresh.set(true); + let _ = self.history_scroll.set(10_000.0); + } + + /// Set the expression to a previously computed result (e.g., when clicking a history entry). + pub fn use_history_result(&self, result: String) { + let _ = self.expression.set(result); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + let _ = self.fresh.set(false); + } + + pub fn toggle_mode(&self) { + self.mode.update(|m| { + *m = match *m { + CalcMode::Basic => CalcMode::Scientific, + CalcMode::Scientific => CalcMode::Basic, + }; + }); + } + + /// Insert the right parenthesis character based on current balance. + pub fn input_paren(&self) { + let open = self + .expression + .with(|e| e.chars().filter(|&c| c == '(').count()); + let close = self + .expression + .with(|e| e.chars().filter(|&c| c == ')').count()); + self.input(if open > close { ")" } else { "(" }); + } +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +/// Evaluate `raw_expr`, preprocessing display symbols to meval-compatible syntax. +pub fn evaluate(raw_expr: &str) -> EvalResult { + let expr = raw_expr + .replace('×', "*") + .replace('÷', "/") + .replace('π', "pi") + // √( is inserted by the scientific button; meval knows sqrt + .replace("√", "sqrt"); + + let mut ctx = meval::Context::new(); + // Add factorial — only integers 0..=20 to avoid overflow + ctx.func("fact", |x: f64| { + if x < 0.0 || x.fract() != 0.0 || x > 20.0 { + return f64::NAN; + } + (1..=(x as u64)).product::() as f64 + }); + + meval::eval_str_with_context(&expr, ctx).map_err(|e| e.to_string()) +} + +/// Format an f64 result: use integer display when possible, otherwise up to 10 sig figs. +fn format_result(value: f64) -> String { + if value.is_nan() { + return "Error".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "∞".to_string() } else { "-∞".to_string() }; + } + if value.fract() == 0.0 && value.abs() < 1e15 { + return format!("{}", value as i64); + } + // Up to 10 significant figures, strip trailing zeros + let s = format!("{:.10}", value); + let s = s.trim_end_matches('0').trim_end_matches('.'); + s.to_string() +} diff --git a/lib/reactivity/Cargo.toml b/lib/reactivity/Cargo.toml index 0006d50..3f40f39 100644 --- a/lib/reactivity/Cargo.toml +++ b/lib/reactivity/Cargo.toml @@ -5,7 +5,12 @@ edition = "2024" [dependencies] ruin_runtime = { package = "ruin-runtime", path = "../runtime" } -tracing = { version = "0.1", default-features = false, features = ["std"] } +tracing = { version = "0.1", default-features = false } +hashbrown = "0.17" [dev-dependencies] -tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "env-filter", + "fmt", + "std", +] } diff --git a/lib/reactivity/src/cell.rs b/lib/reactivity/src/cell.rs index 412acb8..f7b8a9e 100644 --- a/lib/reactivity/src/cell.rs +++ b/lib/reactivity/src/cell.rs @@ -1,5 +1,5 @@ -use std::cell::RefCell; -use std::rc::Rc; +use alloc::rc::Rc; +use core::cell::RefCell; use crate::{NodeId, Reactor, current, trace_targets}; @@ -114,7 +114,7 @@ impl Cell { return None; } - let previous = std::mem::replace(&mut *current, value); + let previous = core::mem::replace(&mut *current, value); drop(current); tracing::debug!( target: trace_targets::CELL, diff --git a/lib/reactivity/src/effect.rs b/lib/reactivity/src/effect.rs index 2e2d23a..2e9c6ee 100644 --- a/lib/reactivity/src/effect.rs +++ b/lib/reactivity/src/effect.rs @@ -1,5 +1,6 @@ -use std::cell::{Cell, RefCell}; -use std::rc::{Rc, Weak}; +use alloc::boxed::Box; +use alloc::rc::{Rc, Weak}; +use core::cell::{Cell, RefCell}; use crate::reactor::ObserverHook; use crate::{NodeId, Reactor, current, trace_targets}; @@ -64,7 +65,7 @@ impl EffectHandle { /// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need /// to later dispose of. pub fn leak(self) { - std::mem::forget(self); + core::mem::forget(self); } /// Disposes the effect immediately. diff --git a/lib/reactivity/src/event.rs b/lib/reactivity/src/event.rs index ff5e941..4490f74 100644 --- a/lib/reactivity/src/event.rs +++ b/lib/reactivity/src/event.rs @@ -1,6 +1,9 @@ -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::rc::Rc; +use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::vec::Vec; +use core::cell::{Cell, RefCell}; + +use hashbrown::HashMap; use crate::{NodeId, Reactor, current, trace_targets}; diff --git a/lib/reactivity/src/lib.rs b/lib/reactivity/src/lib.rs index 4343c60..10a54db 100644 --- a/lib/reactivity/src/lib.rs +++ b/lib/reactivity/src/lib.rs @@ -4,6 +4,11 @@ //! single-threaded and designed to live on a runtime-managed thread, while async work feeds it //! from the edges by updating state or emitting events. +#![feature(thread_local)] +#![cfg_attr(not(test), no_std)] + +extern crate alloc; + pub(crate) mod trace_targets { pub const GRAPH: &str = "ruin_reactivity::graph"; pub const CELL: &str = "ruin_reactivity::cell"; @@ -18,6 +23,7 @@ mod effect; mod event; mod id; mod reactor; +mod source; mod thunk; pub use cell::{Cell, cell, cell_in}; @@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in}; pub use event::{Event, Subscription, event, event_in, on, on_in}; pub use id::NodeId; pub use reactor::{ReactCycleError, Reactor, current}; +pub use source::{Source, source, source_in}; pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in}; diff --git a/lib/reactivity/src/reactor.rs b/lib/reactivity/src/reactor.rs index 3b29941..5933dbd 100644 --- a/lib/reactivity/src/reactor.rs +++ b/lib/reactivity/src/reactor.rs @@ -1,9 +1,12 @@ -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::error::Error; -use std::fmt; -use std::panic::panic_any; -use std::rc::{Rc, Weak}; +use alloc::boxed::Box; +use alloc::collections::VecDeque; +use alloc::rc::{Rc, Weak}; +use alloc::vec::Vec; +use core::cell::{Cell, RefCell}; +use core::error::Error; +use core::fmt; + +use hashbrown::{HashMap, HashSet}; use ruin_runtime::queue_microtask; @@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets}; type Job = Box; -thread_local! { - static CURRENT_REACTOR: RefCell> = const { RefCell::new(Weak::new()) }; -} +#[thread_local] +static CURRENT_REACTOR: RefCell> = const { RefCell::new(Weak::new()) }; /// Returns the current thread's default reactor. /// @@ -28,7 +30,7 @@ pub(crate) trait ObserverHook { fn notify(&self); } -/// Panic payload emitted when the reactor detects a dependency cycle. +/// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReactCycleError { cycle: Vec, @@ -85,26 +87,24 @@ impl Reactor { /// Returns the current thread's default reactor. pub fn current() -> Self { - CURRENT_REACTOR.with(|slot| { - if let Some(inner) = slot.borrow().upgrade() { - #[cfg(debug_assertions)] - tracing::trace!( - target: trace_targets::GRAPH, - event = "current_reactor_reuse", - "reusing current thread default reactor" - ); - return Self { inner }; - } - - let reactor = Self::new(); - *slot.borrow_mut() = Rc::downgrade(&reactor.inner); - tracing::debug!( + if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() { + #[cfg(debug_assertions)] + tracing::trace!( target: trace_targets::GRAPH, - event = "current_reactor_install", - "installed current thread default reactor" + event = "current_reactor_reuse", + "reusing current thread default reactor" ); - reactor - }) + return Self { inner }; + } + + let reactor = Self::new(); + *CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner); + tracing::debug!( + target: trace_targets::GRAPH, + event = "current_reactor_install", + "installed current thread default reactor" + ); + reactor } /// Runs `f` in the dependency-tracking scope of `observer`. @@ -143,13 +143,9 @@ impl Reactor { f() } - /// Records that the currently executing observer depends on `observable`. - /// - /// # Panics - /// - /// Panics with [`ReactCycleError`] if `observable` is already being computed in the current - /// dependency stack. - pub fn observe(&self, observable: NodeId) { + /// Attempts to record a dependency on `observable` for the currently running observer, returning an + /// error if doing so would create a dependency cycle. + pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> { if self .inner .active_computations @@ -170,12 +166,12 @@ impl Reactor { cycle_len = cycle.len(), "reactive cycle detected" ); - panic_any(ReactCycleError::new(cycle)); + return Err(ReactCycleError::new(cycle)); } let current = self.inner.stack.borrow().last().copied(); let Some(observer) = current else { - return; + return Ok(()); }; #[cfg(debug_assertions)] @@ -199,6 +195,18 @@ impl Reactor { .entry(observable) .or_default() .insert(observer); + Ok(()) + } + + /// Records a dependency on `observable` for the currently running observer. + /// + /// # Panics + /// + /// Panics if recording the dependency would create a cycle in the reactive graph. + pub fn observe(&self, observable: NodeId) { + if let Err(e) = self.try_observe(observable) { + panic!("reactive cycle detected: {e}"); + } } /// Notifies dependents of `observable`. @@ -344,11 +352,11 @@ impl fmt::Debug for Reactor { struct ReactorInner { next_node: Cell, - dependencies: RefCell>>, - dependents: RefCell>>, - observers: RefCell>>, + dependencies: RefCell>>, + dependents: RefCell>>, + observers: RefCell>>, stack: RefCell>, - active_computations: RefCell>, + active_computations: RefCell>, pending_jobs: RefCell>, flush_scheduled: Cell, } @@ -357,11 +365,11 @@ impl ReactorInner { fn new() -> Self { Self { next_node: Cell::new(1), - dependencies: RefCell::new(BTreeMap::new()), - dependents: RefCell::new(BTreeMap::new()), - observers: RefCell::new(BTreeMap::new()), + dependencies: RefCell::new(HashMap::new()), + dependents: RefCell::new(HashMap::new()), + observers: RefCell::new(HashMap::new()), stack: RefCell::new(Vec::new()), - active_computations: RefCell::new(BTreeSet::new()), + active_computations: RefCell::new(HashSet::new()), pending_jobs: RefCell::new(VecDeque::new()), flush_scheduled: Cell::new(false), } @@ -421,7 +429,7 @@ mod tests { use ruin_runtime::{queue_task, run}; - use super::{ReactCycleError, Reactor, current}; + use super::{Reactor, current}; #[test] fn current_reactor_is_thread_local_singleton() { @@ -436,7 +444,11 @@ mod tests { let observer = reactor.allocate_node(); let observable = reactor.allocate_node(); - reactor.run_in_context(observer, || reactor.observe(observable)); + reactor.run_in_context(observer, || { + reactor + .try_observe(observable) + .expect("should not detect cycle") + }); assert_eq!( reactor.inner.dependencies.borrow().get(&observer), @@ -449,7 +461,7 @@ mod tests { } #[test] - fn cycle_detection_panics_with_structured_error() { + fn cycle_detection_panics_with_expected() { let reactor = Reactor::new(); let a = reactor.allocate_node(); let b = reactor.allocate_node(); @@ -457,15 +469,26 @@ mod tests { let panic = catch_unwind(AssertUnwindSafe(|| { reactor.run_in_context(a, || { reactor.observe(b); - reactor.run_in_context(b, || reactor.observe(a)); + reactor.run_in_context(b, || { + reactor.observe(a); + }); }); })) .expect_err("cycle should panic"); - let error = panic - .downcast::() - .expect("panic payload should be ReactCycleError"); - assert_eq!(error.cycle(), &[a, b, a]); + let Some(cycle_error) = panic.downcast_ref::() else { + panic!("panic should be a string"); + }; + + assert!( + cycle_error.contains("reactive cycle detected"), + "panic should indicate cycle detected" + ); + + assert!( + cycle_error.contains("1 -> 2 -> 1"), + "panic should include cycle path" + ); } #[test] diff --git a/lib/reactivity/src/source.rs b/lib/reactivity/src/source.rs new file mode 100644 index 0000000..2733baa --- /dev/null +++ b/lib/reactivity/src/source.rs @@ -0,0 +1,70 @@ +use alloc::rc::Rc; + +use crate::{NodeId, Reactor, current, trace_targets}; + +/// Creates a low-level reactive source node in the current reactor. +pub fn source() -> Source { + current().source() +} + +/// Creates a low-level reactive source node associated with `reactor`. +pub fn source_in(reactor: &Reactor) -> Source { + reactor.source() +} + +/// Low-level observable source node. +/// +/// `Source` is useful for advanced data structures that want precise control over when reads +/// observe and writes trigger invalidation without storing their state in a [`crate::Cell`]. +#[derive(Clone)] +pub struct Source { + inner: Rc, +} + +impl Reactor { + /// Creates a low-level source node associated with this reactor. + pub fn source(&self) -> Source { + Source::new(self.clone()) + } +} + +impl Source { + fn new(reactor: Reactor) -> Self { + let id = reactor.allocate_node(); + tracing::debug!( + target: trace_targets::GRAPH, + event = "create_source", + node_id = id.0, + "created low-level reactive source" + ); + Self { + inner: Rc::new(SourceInner { reactor, id }), + } + } + + /// Records a dependency on this source for the currently running observer. + pub fn observe(&self) { + self.inner.reactor.observe(self.inner.id); + } + + /// Triggers this source's dependents. + pub fn trigger(&self) { + self.inner.reactor.trigger(self.inner.id); + } + + /// Returns the source node id. + pub fn id(&self) -> NodeId { + self.inner.id + } +} + +struct SourceInner { + reactor: Reactor, + id: NodeId, +} + +impl Drop for SourceInner { + fn drop(&mut self) { + self.reactor.dispose(self.id); + } +} diff --git a/lib/reactivity/src/thunk.rs b/lib/reactivity/src/thunk.rs index 43f9be5..e18d66f 100644 --- a/lib/reactivity/src/thunk.rs +++ b/lib/reactivity/src/thunk.rs @@ -1,5 +1,6 @@ -use std::cell::{Cell, RefCell}; -use std::rc::Rc; +use alloc::boxed::Box; +use alloc::rc::Rc; +use core::cell::{Cell, RefCell}; use crate::reactor::ObserverHook; use crate::{NodeId, Reactor, current, trace_targets}; @@ -428,9 +429,13 @@ mod tests { .expect_err("mutually recursive thunks should panic"); let error = panic - .downcast::() - .expect("panic payload should be ReactCycleError"); - assert_eq!(error.cycle().len(), 3); + .downcast_ref::() + .expect("panic should be a string"); + + assert!( + error.contains("reactive cycle detected"), + "panic should indicate cycle detected" + ); } #[test] diff --git a/lib/reactivity_parsing/Cargo.toml b/lib/reactivity_parsing/Cargo.toml new file mode 100644 index 0000000..e33d721 --- /dev/null +++ b/lib/reactivity_parsing/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ruin-reactivity-parsing" +version = "0.1.0" +edition = "2024" + +[lib] +name = "ruin_reactivity_parsing" + +[dependencies] +ruin_reactivity = { path = "../reactivity" } + +[dev-dependencies] +ruin_runtime = { package = "ruin-runtime", path = "../runtime" } diff --git a/lib/reactivity_parsing/examples/json_reactive_parser.rs b/lib/reactivity_parsing/examples/json_reactive_parser.rs new file mode 100644 index 0000000..98baaab --- /dev/null +++ b/lib/reactivity_parsing/examples/json_reactive_parser.rs @@ -0,0 +1,745 @@ +use std::env; +use std::fmt; +use std::rc::Rc; + +use ruin_reactivity::{cell_in, effect_in}; +use ruin_reactivity_parsing::{ + Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange, +}; +use ruin_runtime::{async_main, fs, stdio}; + +#[derive(Clone, Debug)] +struct ParseSnapshot { + text: String, + root_anchor: Anchor, + root: AstNode, + reused_nodes: usize, + total_nodes: usize, + diagnostics: Vec, +} + +#[derive(Clone, Debug)] +struct AstNode { + id: u64, + kind: JsonKind, + local_range: TextRange, + children: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum JsonKind { + Object, + Array, + Pair, + String, + Number, + True, + False, + Null, +} + +impl fmt::Display for JsonKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Object => "object", + Self::Array => "array", + Self::Pair => "pair", + Self::String => "string", + Self::Number => "number", + Self::True => "true", + Self::False => "false", + Self::Null => "null", + }; + write!(f, "{label}") + } +} + +#[derive(Clone, Debug)] +struct ParsedNode { + kind: JsonKind, + range: TextRange, + children: Vec, +} + +#[derive(Default)] +struct JsonParser { + next_id: u64, +} + +struct Cursor<'a> { + text: &'a str, + offset: usize, +} + +#[derive(Clone)] +enum Command { + Insert { offset: usize, text: String }, + Replace { start: usize, end: usize, text: String }, + ReplaceAll { text: String }, + Delete { start: usize, end: usize }, + Print, + Help, + Quit, +} + +#[async_main] +async fn main() { + let path = env::args() + .nth(1) + .expect("usage: cargo run -p ruin-reactivity-parsing --example json_reactive_parser -- "); + let initial_text = fs::read_to_string(&path) + .await + .expect("json file should be readable"); + + let reactor = ruin_reactivity::Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, &initial_text); + let parser = Rc::new(std::cell::RefCell::new(JsonParser::default())); + + let initial_snapshot = parser + .borrow_mut() + .parse_initial(&rope) + .expect("initial JSON should parse"); + let parse_state = cell_in(&reactor, initial_snapshot); + let last_delta = cell_in(&reactor, None::); + + let _parser_subscription = { + let rope = rope.clone(); + let parser = Rc::clone(&parser); + let parse_state = parse_state.clone(); + let last_delta = last_delta.clone(); + rope.edit_events().subscribe(move |delta| { + let previous = parse_state.get(); + match parser.borrow_mut().parse_incremental(&rope, &previous, delta) { + Ok(next) => { + last_delta.replace(Some(delta.clone())); + parse_state.replace(next); + } + Err(error) => { + eprintln!("parse error after edit: {error}"); + eprintln!( + "note: current text length is {} bytes; use `replace_all ` to replace the full document without manual offsets", + rope.len_bytes() + ); + } + } + }); + }; + + effect_in(&reactor, { + let rope = rope.clone(); + let parse_state = parse_state.clone(); + let last_delta = last_delta.clone(); + move || { + parse_state.with(|snapshot| print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())); + } + }) + .leak(); + + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + + let mut input = stdio::stdin().expect("stdin should be available"); + while let Some(line) = input.read_line().await.expect("stdin read should succeed") { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + + match parse_command(trimmed) { + Ok(Command::Insert { offset, text }) => { + let _ = rope.edit(RopeEdit::insert(offset, text)); + } + Ok(Command::Replace { start, end, text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text)); + } + Ok(Command::ReplaceAll { text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text)); + } + Ok(Command::Delete { start, end }) => { + let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end))); + } + Ok(Command::Print) => { + let snapshot = parse_state.get(); + print_snapshot(&snapshot, &rope, None); + } + Ok(Command::Help) => { + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + } + Ok(Command::Quit) => break, + Err(error) => eprintln!("{error}"), + } + } +} + +fn parse_command(input: &str) -> Result { + if input == "quit" { + return Ok(Command::Quit); + } + if input == "print" { + return Ok(Command::Print); + } + if input == "help" { + return Ok(Command::Help); + } + if let Some(rest) = input.strip_prefix("insert ") { + let (offset, text) = rest + .split_once(' ') + .ok_or_else(|| "insert expects: insert ".to_string())?; + return Ok(Command::Insert { + offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("replace ") { + let mut parts = rest.splitn(3, ' '); + let start = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let end = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let text = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + return Ok(Command::Replace { + start: start.parse().map_err(|_| "invalid replace start".to_string())?, + end: end.parse().map_err(|_| "invalid replace end".to_string())?, + text: text.to_string(), + }); + } + if let Some(text) = input.strip_prefix("replace_all ") { + return Ok(Command::ReplaceAll { + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("delete ") { + let (start, end) = rest + .split_once(' ') + .ok_or_else(|| "delete expects: delete ".to_string())?; + return Ok(Command::Delete { + start: start.parse().map_err(|_| "invalid delete start".to_string())?, + end: end.parse().map_err(|_| "invalid delete end".to_string())?, + }); + } + Err("unknown command; type `help`".to_string()) +} + +impl JsonParser { + fn parse_initial(&mut self, rope: &ReactiveRope) -> Result { + let text = read_rope_text(rope); + let parsed = parse_json(&text)?; + let root = self.materialize_fresh(&parsed, 0); + Ok(ParseSnapshot { + text, + root_anchor: Anchor::new(0, AnchorBias::Left), + total_nodes: count_nodes(&root), + reused_nodes: 0, + diagnostics: Vec::new(), + root, + }) + } + + fn parse_incremental( + &mut self, + rope: &ReactiveRope, + previous: &ParseSnapshot, + delta: &EditDelta, + ) -> Result { + let text = read_rope_text(rope); + let parsed = parse_json(&text)?; + let mut root_anchor = previous.root_anchor; + root_anchor.map_through(delta); + let mut reused = 0usize; + let root = self.reuse_node( + &parsed, + Some(&previous.root), + previous.root_anchor.byte, + root_anchor.byte, + &previous.text, + &text, + delta, + &mut reused, + ); + Ok(ParseSnapshot { + text, + root_anchor, + total_nodes: count_nodes(&root), + reused_nodes: reused, + diagnostics: Vec::new(), + root, + }) + } + + fn materialize_fresh(&mut self, parsed: &ParsedNode, parent_start: usize) -> AstNode { + AstNode { + id: self.alloc_id(), + kind: parsed.kind, + local_range: localize(parsed.range, parent_start), + children: parsed + .children + .iter() + .map(|child| self.materialize_fresh(child, parsed.range.start)) + .collect(), + } + } + + fn reuse_node( + &mut self, + parsed: &ParsedNode, + old: Option<&AstNode>, + old_parent_start: usize, + new_parent_start: usize, + old_text: &str, + new_text: &str, + delta: &EditDelta, + reused: &mut usize, + ) -> AstNode { + let parsed_local = localize(parsed.range, new_parent_start); + if let Some(old) = old + && old.kind == parsed.kind + && map_range(absolute_range(old, old_parent_start), delta) == parsed.range + && old_text + .get(absolute_range(old, old_parent_start).start..absolute_range(old, old_parent_start).end) + == new_text.get(parsed.range.start..parsed.range.end) + { + *reused += count_nodes(old); + return AstNode { + id: old.id, + kind: old.kind, + local_range: parsed_local, + children: old.children.clone(), + }; + } + + let children = parsed + .children + .iter() + .enumerate() + .map(|(index, child)| { + let previous_child = old.and_then(|node| node.children.get(index)); + let next_old_parent_start = old + .map(|node| absolute_range(node, old_parent_start).start) + .unwrap_or(old_parent_start); + self.reuse_node( + child, + previous_child, + next_old_parent_start, + parsed.range.start, + old_text, + new_text, + delta, + reused, + ) + }) + .collect(); + + AstNode { + id: self.alloc_id(), + kind: parsed.kind, + local_range: parsed_local, + children, + } + } + + fn alloc_id(&mut self) -> u64 { + self.next_id = self.next_id.wrapping_add(1); + self.next_id + } +} + +fn read_rope_text(rope: &ReactiveRope) -> String { + let mut cursor = rope.cursor(0); + let mut text = String::new(); + while let Some(chunk) = cursor.next_chunk() { + text.push_str(chunk.text()); + } + text +} + +fn localize(range: TextRange, parent_start: usize) -> TextRange { + TextRange::new( + range.start.saturating_sub(parent_start), + range.end.saturating_sub(parent_start), + ) +} + +fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange { + TextRange::new( + parent_start + node.local_range.start, + parent_start + node.local_range.end, + ) +} + +fn map_range(range: TextRange, delta: &EditDelta) -> TextRange { + if range.end <= delta.range.start { + return range; + } + if range.start >= delta.range.end { + let shift = delta.net_byte_delta(); + return TextRange::new( + range.start.saturating_add_signed(shift), + range.end.saturating_add_signed(shift), + ); + } + TextRange::new(usize::MAX, usize::MAX) +} + +fn parse_json(text: &str) -> Result { + let mut cursor = Cursor { text, offset: 0 }; + cursor.skip_ws(); + let node = cursor.parse_value()?; + cursor.skip_ws(); + if !cursor.is_eof() { + return Err(format!("unexpected trailing data at byte {}", cursor.offset)); + } + Ok(node) +} + +impl<'a> Cursor<'a> { + fn is_eof(&self) -> bool { + self.offset >= self.text.len() + } + + fn skip_ws(&mut self) { + while let Some(ch) = self.peek_char() { + if ch.is_ascii_whitespace() { + self.offset += ch.len_utf8(); + } else { + break; + } + } + } + + fn peek_char(&self) -> Option { + self.text[self.offset..].chars().next() + } + + fn bump_char(&mut self) -> Option { + let ch = self.peek_char()?; + self.offset += ch.len_utf8(); + Some(ch) + } + + fn expect_char(&mut self, expected: char) -> Result<(), String> { + match self.bump_char() { + Some(ch) if ch == expected => Ok(()), + Some(ch) => Err(format!( + "expected `{expected}` at byte {}, found `{ch}`", + self.offset.saturating_sub(ch.len_utf8()) + )), + None => Err(format!("expected `{expected}` at end of file")), + } + } + + fn parse_value(&mut self) -> Result { + self.skip_ws(); + match self.peek_char() { + Some('{') => self.parse_object(), + Some('[') => self.parse_array(), + Some('"') => self.parse_string(), + Some('t') => self.parse_literal("true", JsonKind::True), + Some('f') => self.parse_literal("false", JsonKind::False), + Some('n') => self.parse_literal("null", JsonKind::Null), + Some('-' | '0'..='9') => self.parse_number(), + Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)), + None => Err("unexpected end of JSON input".to_string()), + } + } + + fn parse_object(&mut self) -> Result { + let start = self.offset; + self.expect_char('{')?; + self.skip_ws(); + let mut children = Vec::new(); + if self.peek_char() == Some('}') { + self.bump_char(); + return Ok(ParsedNode { + kind: JsonKind::Object, + range: TextRange::new(start, self.offset), + children, + }); + } + + loop { + self.skip_ws(); + let key = self.parse_string()?; + self.skip_ws(); + self.expect_char(':')?; + self.skip_ws(); + let value = self.parse_value()?; + let pair = ParsedNode { + kind: JsonKind::Pair, + range: TextRange::new(key.range.start, value.range.end), + children: vec![key, value], + }; + children.push(pair); + self.skip_ws(); + match self.peek_char() { + Some(',') => { + self.bump_char(); + } + Some('}') => { + self.bump_char(); + break; + } + Some(other) => { + return Err(format!("expected `,` or `}}` at byte {}, found `{other}`", self.offset)); + } + None => return Err("unterminated object".to_string()), + } + } + + Ok(ParsedNode { + kind: JsonKind::Object, + range: TextRange::new(start, self.offset), + children, + }) + } + + fn parse_array(&mut self) -> Result { + let start = self.offset; + self.expect_char('[')?; + self.skip_ws(); + let mut children = Vec::new(); + if self.peek_char() == Some(']') { + self.bump_char(); + return Ok(ParsedNode { + kind: JsonKind::Array, + range: TextRange::new(start, self.offset), + children, + }); + } + + loop { + let value = self.parse_value()?; + children.push(value); + self.skip_ws(); + match self.peek_char() { + Some(',') => { + self.bump_char(); + self.skip_ws(); + } + Some(']') => { + self.bump_char(); + break; + } + Some(other) => { + return Err(format!("expected `,` or `]` at byte {}, found `{other}`", self.offset)); + } + None => return Err("unterminated array".to_string()), + } + } + + Ok(ParsedNode { + kind: JsonKind::Array, + range: TextRange::new(start, self.offset), + children, + }) + } + + fn parse_string(&mut self) -> Result { + let start = self.offset; + self.expect_char('"')?; + loop { + match self.bump_char() { + Some('"') => { + return Ok(ParsedNode { + kind: JsonKind::String, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }); + } + Some('\\') => match self.bump_char() { + Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n') + | Some('r') | Some('t') => {} + Some('u') => { + for _ in 0..4 { + match self.bump_char() { + Some(ch) if ch.is_ascii_hexdigit() => {} + _ => return Err(format!("invalid unicode escape at byte {}", self.offset)), + } + } + } + _ => return Err(format!("invalid escape sequence at byte {}", self.offset)), + }, + Some(ch) if ch >= '\u{20}' => {} + Some(_) => return Err(format!("control character in string at byte {}", self.offset)), + None => return Err("unterminated string".to_string()), + } + } + } + + fn parse_number(&mut self) -> Result { + let start = self.offset; + if self.peek_char() == Some('-') { + self.bump_char(); + } + + match self.peek_char() { + Some('0') => { + self.bump_char(); + } + Some('1'..='9') => { + self.bump_char(); + while matches!(self.peek_char(), Some('0'..='9')) { + self.bump_char(); + } + } + _ => return Err(format!("invalid number at byte {}", self.offset)), + } + + if self.peek_char() == Some('.') { + self.bump_char(); + let mut digits = 0usize; + while matches!(self.peek_char(), Some('0'..='9')) { + digits += 1; + self.bump_char(); + } + if digits == 0 { + return Err(format!("expected fraction digits at byte {}", self.offset)); + } + } + + if matches!(self.peek_char(), Some('e' | 'E')) { + self.bump_char(); + if matches!(self.peek_char(), Some('+' | '-')) { + self.bump_char(); + } + let mut digits = 0usize; + while matches!(self.peek_char(), Some('0'..='9')) { + digits += 1; + self.bump_char(); + } + if digits == 0 { + return Err(format!("expected exponent digits at byte {}", self.offset)); + } + } + + Ok(ParsedNode { + kind: JsonKind::Number, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }) + } + + fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result { + let start = self.offset; + if !self.text[self.offset..].starts_with(literal) { + return Err(format!("expected `{literal}` at byte {}", self.offset)); + } + self.offset += literal.len(); + Ok(ParsedNode { + kind, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }) + } +} + +fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) { + let metrics = rope.snapshot_metrics(); + println!(); + println!("Document revision {}", metrics.revision); + println!( + "Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}", + metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes + ); + if let Some(delta) = delta { + println!( + "Last edit: [{}..{}) -> {:?}", + delta.range.start, delta.range.end, delta.inserted_text + ); + let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1)); + let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus); + println!("Changed path:"); + for (node, range) in path { + let start = rope.point_at(range.start); + let end = rope.point_at(range.end); + println!( + " id={} kind={} span={}", + node.id, + node.kind, + format_span(start, end) + ); + } + } + if !snapshot.diagnostics.is_empty() { + println!("Diagnostics:"); + for diagnostic in &snapshot.diagnostics { + println!(" - {diagnostic}"); + } + } + println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind); + println!("Parse tree:"); + print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0); + println!("Current text:"); + println!("{}", snapshot.text); +} + +fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) { + let range = absolute_range(node, parent_start); + let start = rope.point_at(range.start); + let end = rope.point_at(range.end); + let indent = " ".repeat(depth); + println!( + "{indent}- id={} kind={} span={}", + node.id, + node.kind, + format_span(start, end) + ); + for child in &node.children { + print_tree(child, range.start, rope, depth + 1); + } +} + +fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> { + fn walk<'a>( + node: &'a AstNode, + parent_start: usize, + offset: usize, + out: &mut Vec<(&'a AstNode, TextRange)>, + ) -> bool { + let range = absolute_range(node, parent_start); + if offset < range.start || offset >= range.end { + return false; + } + out.push((node, range)); + for child in &node.children { + if walk(child, range.start, offset, out) { + return true; + } + } + true + } + + let mut path = Vec::new(); + let _ = walk(root, root_start, offset, &mut path); + path +} + +fn format_span(start: TextPoint, end: TextPoint) -> String { + format!( + "{}:{}..{}:{}", + start.line + 1, + start.column + 1, + end.line + 1, + end.column + 1 + ) +} + +fn count_nodes(node: &AstNode) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} diff --git a/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs b/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs new file mode 100644 index 0000000..07322c6 --- /dev/null +++ b/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs @@ -0,0 +1,207 @@ +use std::env; +use std::rc::Rc; + +use ruin_reactivity::effect_in; +use ruin_reactivity_parsing::{ + ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse, + TokenTreeUpdate, +}; +use ruin_runtime::{async_main, fs, stdio}; + +enum Command { + Insert { offset: usize, text: String }, + Replace { start: usize, end: usize, text: String }, + ReplaceAll { text: String }, + Delete { start: usize, end: usize }, + Print, + Help, + Quit, +} + +#[async_main] +async fn main() { + let path = env::args() + .nth(1) + .expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- "); + let initial_text = fs::read_to_string(&path) + .await + .expect("source file should be readable"); + + let reactor = ruin_reactivity::Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, &initial_text); + let parser = Rc::new(ReactiveTokenTree::new(&rope)); + + effect_in(&reactor, { + let parser = Rc::clone(&parser); + let rope = rope.clone(); + move || { + parser.with_snapshot(|snapshot| { + let last_update = parser.last_update(); + print_snapshot(snapshot, &rope, last_update.as_ref()); + }); + } + }) + .leak(); + + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + + let mut input = stdio::stdin().expect("stdin should be available"); + while let Some(line) = input.read_line().await.expect("stdin read should succeed") { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + + match parse_command(trimmed) { + Ok(Command::Insert { offset, text }) => { + let _ = rope.edit(RopeEdit::insert(offset, text)); + } + Ok(Command::Replace { start, end, text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text)); + } + Ok(Command::ReplaceAll { text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text)); + } + Ok(Command::Delete { start, end }) => { + let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end))); + } + Ok(Command::Print) => { + let snapshot = parser.snapshot(); + let last_update = parser.last_update(); + print_snapshot(&snapshot, &rope, last_update.as_ref()); + } + Ok(Command::Help) => { + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + } + Ok(Command::Quit) => break, + Err(error) => eprintln!("{error}"), + } + } +} + +fn parse_command(input: &str) -> Result { + if input == "quit" { + return Ok(Command::Quit); + } + if input == "print" { + return Ok(Command::Print); + } + if input == "help" { + return Ok(Command::Help); + } + if let Some(rest) = input.strip_prefix("insert ") { + let (offset, text) = rest + .split_once(' ') + .ok_or_else(|| "insert expects: insert ".to_string())?; + return Ok(Command::Insert { + offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("replace ") { + let mut parts = rest.splitn(3, ' '); + let start = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let end = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let text = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + return Ok(Command::Replace { + start: start.parse().map_err(|_| "invalid replace start".to_string())?, + end: end.parse().map_err(|_| "invalid replace end".to_string())?, + text: text.to_string(), + }); + } + if let Some(text) = input.strip_prefix("replace_all ") { + return Ok(Command::ReplaceAll { + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("delete ") { + let (start, end) = rest + .split_once(' ') + .ok_or_else(|| "delete expects: delete ".to_string())?; + return Ok(Command::Delete { + start: start.parse().map_err(|_| "invalid delete start".to_string())?, + end: end.parse().map_err(|_| "invalid delete end".to_string())?, + }); + } + Err("unknown command; type `help`".to_string()) +} + +fn print_snapshot(snapshot: &TokenTreeParse, rope: &ReactiveRope, update: Option<&TokenTreeUpdate>) { + let metrics = rope.snapshot_metrics(); + println!(); + println!("Document revision {}", metrics.revision); + println!( + "Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}", + metrics.text.bytes, + metrics.leaves, + snapshot.stats.total_nodes, + snapshot.stats.reused_nodes + ); + if let Some(update) = update { + println!( + "Last edit: [{}..{}) -> {:?}", + update.delta.range.start, + update.delta.range.end, + update.delta.inserted_text + ); + println!( + "Diagnostics changed={}, trivia changed={}", + update.diagnostics_changed, + update.trivia_changed + ); + } + println!("Parse tree:"); + print_tree(snapshot, rope, &snapshot.root, 0); + if !snapshot.diagnostics.is_empty() { + println!("Diagnostics:"); + for diagnostic in &snapshot.diagnostics { + println!( + " {:?} [{}..{}): {}", + diagnostic.kind, diagnostic.range.start, diagnostic.range.end, diagnostic.message + ); + } + } + if !snapshot.trivia.comments().is_empty() { + println!("Comments:"); + for comment in snapshot.trivia.comments() { + println!( + " {:?} [{}..{}): {}", + comment.kind, comment.range.start, comment.range.end, comment.text + ); + } + } +} + +fn print_tree(snapshot: &TokenTreeParse, rope: &ReactiveRope, node: &TokenTreeNode, depth: usize) { + let indent = " ".repeat(depth); + let (start, end) = snapshot + .absolute_span(rope, node.id) + .expect("node span should be available"); + println!( + "{}- id={} kind={} span=({}:{})..({}:{})", + indent, node.id, node.kind, start.line, start.column, end.line, end.column + ); + for child in &node.children { + print_tree(snapshot, rope, child, depth + 1); + } +} diff --git a/lib/reactivity_parsing/src/delta.rs b/lib/reactivity_parsing/src/delta.rs new file mode 100644 index 0000000..5e201c9 --- /dev/null +++ b/lib/reactivity_parsing/src/delta.rs @@ -0,0 +1,152 @@ +use crate::metrics::{TextMetrics, TextPoint}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TextRange { + pub start: usize, + pub end: usize, +} + +impl TextRange { + pub const fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + pub const fn len(self) -> usize { + self.end - self.start + } + + pub const fn is_empty(self) -> bool { + self.start == self.end + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RopeEdit { + pub range: TextRange, + pub insert: String, +} + +impl RopeEdit { + pub fn insert(offset: usize, text: impl Into) -> Self { + Self { + range: TextRange::new(offset, offset), + insert: text.into(), + } + } + + pub fn delete(range: TextRange) -> Self { + Self { + range, + insert: String::new(), + } + } + + pub fn replace(range: TextRange, text: impl Into) -> Self { + Self { + range, + insert: text.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EditDelta { + pub range: TextRange, + pub removed_text: String, + pub inserted_text: String, + pub removed: TextMetrics, + pub inserted: TextMetrics, + pub start: TextPoint, + pub old_end: TextPoint, + pub new_end: TextPoint, +} + +impl EditDelta { + pub fn net_byte_delta(&self) -> isize { + self.inserted.bytes as isize - self.removed.bytes as isize + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnchorBias { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Anchor { + pub byte: usize, + pub bias: AnchorBias, +} + +impl Anchor { + pub const fn new(byte: usize, bias: AnchorBias) -> Self { + Self { byte, bias } + } + + pub fn map_through(&mut self, delta: &EditDelta) { + if self.byte < delta.range.start { + return; + } + + if self.byte > delta.range.end { + self.byte = self.byte.saturating_add_signed(delta.net_byte_delta()); + return; + } + + self.byte = match self.bias { + AnchorBias::Left => delta.range.start, + AnchorBias::Right => delta.range.start + delta.inserted.bytes, + }; + } +} + +#[cfg(test)] +mod tests { + use super::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange}; + use crate::{TextMetrics, TextPoint}; + + #[test] + fn constructors_build_expected_edits() { + assert_eq!(RopeEdit::insert(2, "x").range, TextRange::new(2, 2)); + assert_eq!(RopeEdit::delete(TextRange::new(2, 4)).insert, ""); + assert_eq!(RopeEdit::replace(TextRange::new(1, 3), "zz").insert, "zz"); + } + + #[test] + fn text_range_helpers_report_size() { + let range = TextRange::new(3, 7); + assert_eq!(range.len(), 4); + assert!(!range.is_empty()); + assert!(TextRange::new(5, 5).is_empty()); + } + + #[test] + fn anchors_map_before_inside_and_after_edits() { + let delta = EditDelta { + range: TextRange::new(4, 7), + removed_text: "abc".into(), + inserted_text: "x".into(), + removed: TextMetrics::from_text("abc"), + inserted: TextMetrics::from_text("x"), + start: TextPoint::origin().advance("1234"), + old_end: TextPoint::origin().advance("1234abc"), + new_end: TextPoint::origin().advance("1234x"), + }; + + let mut before = Anchor::new(2, AnchorBias::Left); + let mut inside_left = Anchor::new(5, AnchorBias::Left); + let mut inside_right = Anchor::new(5, AnchorBias::Right); + let mut after = Anchor::new(10, AnchorBias::Left); + + before.map_through(&delta); + inside_left.map_through(&delta); + inside_right.map_through(&delta); + after.map_through(&delta); + + assert_eq!(before.byte, 2); + assert_eq!(inside_left.byte, 4); + assert_eq!(inside_right.byte, 5); + assert_eq!(after.byte, 8); + } +} diff --git a/lib/reactivity_parsing/src/lib.rs b/lib/reactivity_parsing/src/lib.rs new file mode 100644 index 0000000..1bcfe9a --- /dev/null +++ b/lib/reactivity_parsing/src/lib.rs @@ -0,0 +1,24 @@ +//! Reactive rope text storage and incremental parsing substrate for RUIN. + +mod delta; +mod metrics; +mod parse; +mod rope; +mod tokentree; + +pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange}; +pub use metrics::{TextMetrics, TextPoint}; +pub use parse::{ + IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParserCheckpoint, ParseTree, + SyntaxFragment, +}; +pub use rope::{ + DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor, + RopeSnapshotMetrics, +}; +pub use tokentree::{ + DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment, + ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, + TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, + TriviaComment, TriviaKind, TriviaStore, +}; diff --git a/lib/reactivity_parsing/src/metrics.rs b/lib/reactivity_parsing/src/metrics.rs new file mode 100644 index 0000000..89bc253 --- /dev/null +++ b/lib/reactivity_parsing/src/metrics.rs @@ -0,0 +1,139 @@ +use core::fmt; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TextMetrics { + pub bytes: usize, + pub chars: usize, + pub utf16: usize, + pub line_breaks: usize, +} + +impl TextMetrics { + pub fn from_text(text: &str) -> Self { + let mut metrics = Self::default(); + for ch in text.chars() { + metrics.bytes += ch.len_utf8(); + metrics.chars += 1; + metrics.utf16 += ch.len_utf16(); + metrics.line_breaks += usize::from(ch == '\n'); + } + metrics + } +} + +impl core::ops::Add for TextMetrics { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self { + bytes: self.bytes + rhs.bytes, + chars: self.chars + rhs.chars, + utf16: self.utf16 + rhs.utf16, + line_breaks: self.line_breaks + rhs.line_breaks, + } + } +} + +impl core::ops::AddAssign for TextMetrics { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl core::ops::Sub for TextMetrics { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self { + bytes: self.bytes - rhs.bytes, + chars: self.chars - rhs.chars, + utf16: self.utf16 - rhs.utf16, + line_breaks: self.line_breaks - rhs.line_breaks, + } + } +} + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct TextPoint { + pub byte: usize, + pub char_offset: usize, + pub utf16_offset: usize, + pub line: usize, + pub column: usize, +} + +impl TextPoint { + pub const fn origin() -> Self { + Self { + byte: 0, + char_offset: 0, + utf16_offset: 0, + line: 0, + column: 0, + } + } + + pub fn advance(mut self, text: &str) -> Self { + self.byte += text.len(); + for ch in text.chars() { + self.char_offset += 1; + self.utf16_offset += ch.len_utf16(); + if ch == '\n' { + self.line += 1; + self.column = 0; + } else { + self.column += 1; + } + } + self + } +} + +impl fmt::Debug for TextPoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TextPoint") + .field("byte", &self.byte) + .field("char_offset", &self.char_offset) + .field("utf16_offset", &self.utf16_offset) + .field("line", &self.line) + .field("column", &self.column) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::{TextMetrics, TextPoint}; + + #[test] + fn metrics_count_utf8_utf16_and_lines() { + let metrics = TextMetrics::from_text("a\né🙂"); + assert_eq!(metrics.bytes, "a\né🙂".len()); + assert_eq!(metrics.chars, 4); + assert_eq!(metrics.utf16, 5); + assert_eq!(metrics.line_breaks, 1); + } + + #[test] + fn metrics_support_arithmetic() { + let left = TextMetrics::from_text("hello\n"); + let right = TextMetrics::from_text("🙂"); + let combined = left + right; + + assert_eq!(combined.bytes, "hello\n🙂".len()); + assert_eq!(combined.chars, 7); + assert_eq!(combined.utf16, 8); + assert_eq!(combined.line_breaks, 1); + assert_eq!(combined - right, left); + } + + #[test] + fn point_advance_tracks_offsets() { + let point = TextPoint::origin().advance("ab\n🙂"); + assert_eq!(point.byte, "ab\n🙂".len()); + assert_eq!(point.char_offset, 4); + assert_eq!(point.utf16_offset, 5); + assert_eq!(point.line, 1); + assert_eq!(point.column, 1); + } +} diff --git a/lib/reactivity_parsing/src/parse.rs b/lib/reactivity_parsing/src/parse.rs new file mode 100644 index 0000000..367d286 --- /dev/null +++ b/lib/reactivity_parsing/src/parse.rs @@ -0,0 +1,162 @@ +use core::fmt; + +use crate::{RopeCursor, TextRange}; + +pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {} + +impl ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxFragment { + pub range: TextRange, + pub start_checkpoint: C, + pub end_checkpoint: C, + pub tree: T, + pub diagnostics: Vec, + pub grammar_revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseTree { + pub root: T, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseInvalidation { + pub resume_at: usize, + pub restart_checkpoint: C, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseResult { + pub tree: ParseTree, + pub fragments: Vec>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseError { + pub message: String, +} + +impl ParseError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +pub trait IncrementalParser { + type Checkpoint: ParserCheckpoint; + type Tree: Clone + fmt::Debug + 'static; + type Diagnostic: Clone + fmt::Debug + 'static; + + fn grammar_revision(&self) -> u64; + + fn initial_checkpoint(&self) -> Self::Checkpoint; + + fn invalidate( + &self, + delta_range: TextRange, + previous_fragments: &[SyntaxFragment], + ) -> ParseInvalidation; + + fn parse( + &self, + cursor: RopeCursor, + fragments: &[SyntaxFragment], + invalidation: ParseInvalidation, + ) -> Result, ParseError>; +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use ruin_reactivity::Reactor; + + use super::{ + IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, SyntaxFragment, + }; + use crate::{ReactiveRope, RopeCursor, TextRange}; + + #[derive(Clone, Debug)] + struct DummyParser { + parse_calls: Rc>, + } + + impl IncrementalParser for DummyParser { + type Checkpoint = u8; + type Tree = String; + type Diagnostic = String; + + fn grammar_revision(&self) -> u64 { + 7 + } + + fn initial_checkpoint(&self) -> Self::Checkpoint { + 0 + } + + fn invalidate( + &self, + delta_range: TextRange, + _previous_fragments: &[SyntaxFragment], + ) -> ParseInvalidation { + ParseInvalidation { + resume_at: delta_range.start.saturating_sub(1), + restart_checkpoint: 1, + } + } + + fn parse( + &self, + mut cursor: RopeCursor, + _fragments: &[SyntaxFragment], + invalidation: ParseInvalidation, + ) -> Result, ParseError> { + self.parse_calls.set(self.parse_calls.get() + 1); + let text = cursor.read_bytes(usize::MAX); + Ok(ParseResult { + tree: ParseTree { root: text.clone() }, + fragments: vec![SyntaxFragment { + range: TextRange::new(invalidation.resume_at, invalidation.resume_at + text.len()), + start_checkpoint: invalidation.restart_checkpoint, + end_checkpoint: 2, + tree: text, + diagnostics: Vec::new(), + grammar_revision: self.grammar_revision(), + }], + }) + } + } + + #[test] + fn parse_error_constructor_sets_message() { + let error = ParseError::new("bad"); + assert_eq!(error.message, "bad"); + } + + #[test] + fn parser_trait_round_trip_produces_fragments() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "{\"a\":1}"); + let parser = DummyParser { + parse_calls: Rc::new(Cell::new(0)), + }; + + let invalidation = parser.invalidate(TextRange::new(3, 4), &[]); + assert_eq!(invalidation.resume_at, 2); + assert_eq!(invalidation.restart_checkpoint, 1); + + let result = parser + .parse(rope.cursor(0), &[], invalidation) + .expect("dummy parse should succeed"); + + assert_eq!(result.tree.root, "{\"a\":1}"); + assert_eq!(result.fragments.len(), 1); + assert_eq!(result.fragments[0].grammar_revision, 7); + assert_eq!(parser.parse_calls.get(), 1); + } +} diff --git a/lib/reactivity_parsing/src/rope.rs b/lib/reactivity_parsing/src/rope.rs new file mode 100644 index 0000000..b59d713 --- /dev/null +++ b/lib/reactivity_parsing/src/rope.rs @@ -0,0 +1,720 @@ +use std::cell::{Cell, RefCell}; +use std::cmp::min; +use std::collections::BTreeSet; +use std::rc::Rc; + +use ruin_reactivity::{Event, Reactor, Source, event_in, source_in}; + +use crate::delta::{EditDelta, RopeEdit, TextRange}; +use crate::metrics::{TextMetrics, TextPoint}; + +pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024; +const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2; + +#[derive(Clone)] +pub struct ReactiveRope { + inner: Rc, +} + +pub struct RopeBuilder { + reactor: Reactor, + leaf_target_bytes: usize, + chunks: Vec, +} + +#[derive(Clone)] +pub struct RopeChunk { + text: Rc, + pub start: TextPoint, + pub metrics: TextMetrics, +} + +#[derive(Clone)] +pub struct RopeCursor { + rope: ReactiveRope, + next_offset: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RopeSnapshotMetrics { + pub leaves: usize, + pub text: TextMetrics, + pub revision: u64, +} + +struct RopeInner { + reactor: Reactor, + structure: Source, + revision: Cell, + leaf_target_bytes: usize, + leaves: RefCell>>, + edit_events: Event, +} + +struct Leaf { + source: Source, + text: RefCell>, + metrics: Cell, + tail_chars: Cell, +} + +impl RopeBuilder { + pub fn new(reactor: &Reactor) -> Self { + Self { + reactor: reactor.clone(), + leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES, + chunks: Vec::new(), + } + } + + pub fn leaf_target_bytes(mut self, bytes: usize) -> Self { + self.leaf_target_bytes = bytes.max(32); + self + } + + pub fn push(mut self, text: impl Into) -> Self { + self.chunks.push(text.into()); + self + } + + pub fn build(self) -> ReactiveRope { + ReactiveRope::from_chunks(self.reactor, self.leaf_target_bytes, self.chunks) + } +} + +impl ReactiveRope { + pub fn new(reactor: &Reactor) -> Self { + RopeBuilder::new(reactor).build() + } + + pub fn from_text(reactor: &Reactor, text: impl AsRef) -> Self { + RopeBuilder::new(reactor).push(text.as_ref()).build() + } + + fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec) -> Self { + let structure = source_in(&reactor); + let edit_events = event_in(&reactor); + let mut leaves = Vec::new(); + for chunk in chunks { + Self::append_chunk_into(&reactor, leaf_target_bytes, &mut leaves, &chunk); + } + if leaves.is_empty() { + leaves.push(Rc::new(Leaf::new(&reactor, String::new()))); + } + + Self { + inner: Rc::new(RopeInner { + reactor, + structure, + revision: Cell::new(0), + leaf_target_bytes, + leaves: RefCell::new(leaves), + edit_events, + }), + } + } + + pub fn edit_events(&self) -> Event { + self.inner.edit_events.clone() + } + + pub fn reactor(&self) -> Reactor { + self.inner.reactor.clone() + } + + pub fn snapshot_metrics(&self) -> RopeSnapshotMetrics { + self.inner.structure.observe(); + RopeSnapshotMetrics { + leaves: self.inner.leaves.borrow().len(), + text: self.total_metrics(), + revision: self.inner.revision.get(), + } + } + + pub fn len_bytes(&self) -> usize { + self.total_metrics().bytes + } + + pub fn is_empty(&self) -> bool { + self.len_bytes() == 0 + } + + pub fn to_string(&self) -> String { + self.slice(TextRange::new(0, self.len_bytes())) + } + + pub fn point_at(&self, offset: usize) -> TextPoint { + self.inner.structure.observe(); + let offset = offset.min(self.len_bytes()); + let mut point = TextPoint::origin(); + let leaves = self.inner.leaves.borrow(); + let mut remaining = offset; + for leaf in &*leaves { + let bytes = leaf.metrics.get().bytes; + if remaining > bytes { + remaining -= bytes; + point.byte += bytes; + point.char_offset += leaf.metrics.get().chars; + point.utf16_offset += leaf.metrics.get().utf16; + point.line += leaf.metrics.get().line_breaks; + if leaf.metrics.get().line_breaks == 0 { + point.column += leaf.tail_chars.get(); + } else { + point.column = leaf.tail_chars.get(); + } + continue; + } + let text = leaf.read(); + point = point.advance(&text[..remaining]); + return point; + } + point + } + + pub fn slice(&self, range: TextRange) -> String { + self.inner.structure.observe(); + self.assert_char_boundary(range.start); + self.assert_char_boundary(range.end); + assert!(range.start <= range.end, "invalid text range"); + assert!(range.end <= self.len_bytes(), "range out of bounds"); + + let mut out = String::with_capacity(range.len()); + let mut cursor = 0usize; + let leaves = self.inner.leaves.borrow(); + for leaf in &*leaves { + let metrics = leaf.metrics.get(); + let end = cursor + metrics.bytes; + if end <= range.start { + cursor = end; + continue; + } + if cursor >= range.end { + break; + } + let text = leaf.read(); + let start_in_leaf = range.start.saturating_sub(cursor); + let end_in_leaf = min(metrics.bytes, range.end - cursor); + out.push_str(&text[start_in_leaf..end_in_leaf]); + cursor = end; + } + out + } + + pub fn chunk_at(&self, offset: usize) -> Option { + self.inner.structure.observe(); + if offset >= self.len_bytes() { + return None; + } + let mut start = TextPoint::origin(); + let leaves = self.inner.leaves.borrow(); + let mut cursor = 0usize; + for leaf in &*leaves { + let metrics = leaf.metrics.get(); + let end = cursor + metrics.bytes; + if offset < end { + let text = leaf.read(); + leaf.source.observe(); + return Some(RopeChunk { + text, + start, + metrics, + }); + } + start.byte += metrics.bytes; + start.char_offset += metrics.chars; + start.utf16_offset += metrics.utf16; + start.line += metrics.line_breaks; + if metrics.line_breaks == 0 { + start.column += leaf.tail_chars.get(); + } else { + start.column = leaf.tail_chars.get(); + } + cursor = end; + } + None + } + + pub fn cursor(&self, offset: usize) -> RopeCursor { + RopeCursor { + rope: self.clone(), + next_offset: offset.min(self.len_bytes()), + } + } + + pub fn edit(&self, edit: RopeEdit) -> EditDelta { + let delta = self.apply_edit(edit); + self.inner.edit_events.emit(delta.clone()); + delta + } + + pub fn edit_batch(&self, edits: impl IntoIterator) -> Vec { + let mut deltas = Vec::new(); + for edit in edits { + let delta = self.apply_edit(edit); + self.inner.edit_events.emit(delta.clone()); + deltas.push(delta); + } + deltas + } + + fn apply_edit(&self, edit: RopeEdit) -> EditDelta { + assert!(edit.range.start <= edit.range.end, "invalid edit range"); + assert!(edit.range.end <= self.len_bytes(), "edit range out of bounds"); + self.assert_char_boundary(edit.range.start); + self.assert_char_boundary(edit.range.end); + assert!( + edit.insert.is_char_boundary(edit.insert.len()), + "replacement text must be valid UTF-8" + ); + + let old_start = self.point_at(edit.range.start); + let old_end = self.point_at(edit.range.end); + let removed_text = self.slice(edit.range); + let removed = TextMetrics::from_text(&removed_text); + let inserted = TextMetrics::from_text(&edit.insert); + + let mut leaves = self.inner.leaves.borrow_mut(); + let mut cursor = 0usize; + let mut first_index = None; + let mut last_index = None; + let mut prefix = String::new(); + let mut suffix = String::new(); + + for (index, leaf) in leaves.iter().enumerate() { + let text = leaf.read(); + let end = cursor + text.len(); + if first_index.is_none() && edit.range.start <= end { + first_index = Some(index); + let split = edit.range.start.saturating_sub(cursor); + prefix.push_str(&text[..split]); + } + if first_index.is_some() && edit.range.end <= end { + last_index = Some(index); + let split = edit.range.end.saturating_sub(cursor); + suffix.push_str(&text[split..]); + break; + } + cursor = end; + } + + let first_index = first_index.unwrap_or_else(|| leaves.len().saturating_sub(1)); + let last_index = last_index.unwrap_or(first_index); + + let mut replacement = prefix; + replacement.push_str(&edit.insert); + replacement.push_str(&suffix); + + let structure_changed = last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES; + if !structure_changed { + if let Some(leaf) = leaves.get(first_index) { + leaf.set_text(replacement); + leaf.source.trigger(); + } + } else { + let mut replacement_leaves = Vec::new(); + Self::append_chunk_into( + &self.inner.reactor, + self.inner.leaf_target_bytes, + &mut replacement_leaves, + &replacement, + ); + if replacement_leaves.is_empty() { + replacement_leaves.push(Rc::new(Leaf::new(&self.inner.reactor, String::new()))); + } + + let removed_span = leaves.splice(first_index..=last_index, replacement_leaves); + let mut touched = BTreeSet::new(); + for leaf in removed_span { + touched.insert(leaf.source.id()); + } + for leaf in leaves.iter().skip(first_index) { + touched.insert(leaf.source.id()); + } + for leaf in &*leaves { + if touched.contains(&leaf.source.id()) { + leaf.source.trigger(); + } + } + self.inner.structure.trigger(); + } + + self.inner.revision.set(self.inner.revision.get().wrapping_add(1)); + + let new_end = old_start.advance(&edit.insert); + drop(leaves); + + EditDelta { + range: edit.range, + removed_text, + inserted_text: edit.insert, + removed, + inserted, + start: old_start, + old_end, + new_end, + } + } + + fn total_metrics(&self) -> TextMetrics { + self.inner + .leaves + .borrow() + .iter() + .fold(TextMetrics::default(), |sum, leaf| sum + leaf.metrics.get()) + } + + fn append_chunk_into( + reactor: &Reactor, + leaf_target_bytes: usize, + out: &mut Vec>, + text: &str, + ) { + if text.is_empty() { + return; + } + + let max_bytes = (leaf_target_bytes.max(32)) * 2; + let mut start = 0usize; + while start < text.len() { + let mut end = min(text.len(), start + max_bytes); + while end > start && !text.is_char_boundary(end) { + end -= 1; + } + if end == start { + end = text.len(); + } + out.push(Rc::new(Leaf::new(reactor, text[start..end].to_string()))); + start = end; + } + } + + fn assert_char_boundary(&self, offset: usize) { + if offset == self.len_bytes() { + return; + } + let leaves = self.inner.leaves.borrow(); + let mut cursor = 0usize; + for leaf in &*leaves { + let bytes = leaf.metrics.get().bytes; + let end = cursor + bytes; + if offset < end { + let text = leaf.read(); + assert!(text.is_char_boundary(offset - cursor), "offset must be a char boundary"); + return; + } + cursor = end; + } + } +} + +impl RopeChunk { + pub fn text(&self) -> &str { + &self.text + } +} + +impl RopeCursor { + pub fn offset(&self) -> usize { + self.next_offset + } + + pub fn peek_chunk(&self) -> Option { + let chunk = self.rope.chunk_at(self.next_offset)?; + let local = self.next_offset.saturating_sub(chunk.start.byte); + if local == 0 { + return Some(chunk); + } + + let partial = &chunk.text()[local..]; + Some(RopeChunk { + text: Rc::from(partial), + start: self.rope.point_at(self.next_offset), + metrics: TextMetrics::from_text(partial), + }) + } + + pub fn next_chunk(&mut self) -> Option { + let chunk = self.peek_chunk()?; + let end = chunk.start.byte + chunk.metrics.bytes; + self.next_offset = end; + Some(chunk) + } + + pub fn read_bytes(&mut self, len: usize) -> String { + let start = self.next_offset; + let end = min(self.rope.len_bytes(), start + len); + let slice = self.rope.slice(TextRange::new(start, end)); + self.next_offset = end; + slice + } +} + +impl Leaf { + fn new(reactor: &Reactor, text: String) -> Self { + let text: Rc = Rc::from(text); + let metrics = TextMetrics::from_text(&text); + let tail_chars = tail_chars(&text); + Self { + source: source_in(reactor), + text: RefCell::new(text), + metrics: Cell::new(metrics), + tail_chars: Cell::new(tail_chars), + } + } + + fn read(&self) -> Rc { + self.source.observe(); + self.text.borrow().clone() + } + + fn set_text(&self, text: String) { + let text: Rc = Rc::from(text); + self.metrics.set(TextMetrics::from_text(&text)); + self.tail_chars.set(tail_chars(&text)); + *self.text.borrow_mut() = text; + } +} + +fn tail_chars(text: &str) -> usize { + match text.rfind('\n') { + Some(index) => text[index + 1..].chars().count(), + None => text.chars().count(), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell as Counter; + use std::cell::RefCell; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::rc::Rc; + + use ruin_reactivity::{Reactor, thunk_in}; + + use crate::{Anchor, AnchorBias, ReactiveRope, RopeBuilder, RopeEdit, TextRange}; + + #[test] + fn edits_preserve_text_and_metrics() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "hello\nworld"); + + let delta = rope.edit(RopeEdit::replace(TextRange::new(5, 6), ", ")); + + assert_eq!(rope.to_string(), "hello, world"); + assert_eq!(delta.removed_text, "\n"); + assert_eq!(delta.inserted_text, ", "); + assert_eq!(rope.snapshot_metrics().text.bytes, "hello, world".len()); + assert_eq!(rope.snapshot_metrics().text.line_breaks, 0); + } + + #[test] + fn cursor_walks_tokens_across_leaf_boundaries() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("alpha") + .push("beta") + .build(); + let mut cursor = rope.cursor(3); + + assert_eq!(cursor.read_bytes(4), "habe"); + let rest = cursor.next_chunk().expect("remaining chunk"); + assert_eq!(rest.text(), "ta"); + } + + #[test] + fn anchors_map_through_insertions() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abcdef"); + let mut left = Anchor::new(3, AnchorBias::Left); + let mut right = Anchor::new(3, AnchorBias::Right); + + let delta = rope.edit(RopeEdit::insert(3, "XYZ")); + left.map_through(&delta); + right.map_through(&delta); + + assert_eq!(left.byte, 3); + assert_eq!(right.byte, 6); + } + + #[test] + fn disjoint_leaf_reads_do_not_recompute_on_local_edit() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(64) + .push("a".repeat(80)) + .push("b".repeat(80)) + .build(); + + let first_runs = Rc::new(Counter::new(0usize)); + let second_runs = Rc::new(Counter::new(0usize)); + + let first = thunk_in(&reactor, { + let rope = rope.clone(); + let first_runs = Rc::clone(&first_runs); + move || { + first_runs.set(first_runs.get() + 1); + rope.slice(TextRange::new(0, 10)) + } + }); + + let second = thunk_in(&reactor, { + let rope = rope.clone(); + let second_runs = Rc::clone(&second_runs); + move || { + second_runs.set(second_runs.get() + 1); + rope.slice(TextRange::new(90, 100)) + } + }); + + assert_eq!(first.get(), "aaaaaaaaaa"); + assert_eq!(second.get(), "bbbbbbbbbb"); + rope.edit(RopeEdit::replace(TextRange::new(2, 4), "zz")); + + assert_eq!(first.get(), "aazzaaaaaa"); + assert_eq!(second.get(), "bbbbbbbbbb"); + assert_eq!(first_runs.get(), 2); + assert_eq!(second_runs.get(), 1); + } + + #[test] + fn point_at_tracks_multibyte_offsets() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "a\né🙂"); + + assert_eq!(rope.point_at(0).byte, 0); + assert_eq!(rope.point_at(2).line, 1); + assert_eq!(rope.point_at(4).char_offset, 3); + assert_eq!(rope.point_at("a\né🙂".len()).utf16_offset, 5); + assert_eq!(rope.point_at("a\né".len()).column, 1); + } + + #[test] + fn point_at_tracks_column_across_leaves() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("ab\n") + .push("cde") + .build(); + + let point = rope.point_at("ab\ncd".len()); + assert_eq!(point.line, 1); + assert_eq!(point.column, 2); + } + + #[test] + fn chunk_at_and_peek_chunk_respect_boundaries() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("abcd") + .push("efgh") + .build(); + + let first = rope.chunk_at(0).expect("chunk at start"); + assert_eq!(first.text(), "abcd"); + + let second = rope.chunk_at(4).expect("chunk at split"); + assert_eq!(second.text(), "efgh"); + assert!(rope.chunk_at(8).is_none()); + + let mut cursor = rope.cursor(2); + let partial = cursor.peek_chunk().expect("partial chunk"); + assert_eq!(partial.text(), "cd"); + let advanced = cursor.next_chunk().expect("advanced chunk"); + assert_eq!(advanced.text(), "cd"); + assert_eq!(cursor.offset(), 4); + } + + #[test] + fn cursor_stops_at_end_of_document() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("abcd") + .push("ef") + .build(); + let mut cursor = rope.cursor(0); + let mut chunks = Vec::new(); + while let Some(chunk) = cursor.next_chunk() { + chunks.push(chunk.text().to_string()); + } + + assert_eq!(chunks, vec!["abcd".to_string(), "ef".to_string()]); + assert!(cursor.next_chunk().is_none()); + } + + #[test] + fn batch_edits_return_deltas_and_increment_revision() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abcdef"); + let before = rope.snapshot_metrics().revision; + + let deltas = rope.edit_batch([ + RopeEdit::replace(TextRange::new(1, 3), "ZZ"), + RopeEdit::delete(TextRange::new(4, 6)), + ]); + + assert_eq!(deltas.len(), 2); + assert_eq!(rope.to_string(), "aZZd"); + assert_eq!(rope.snapshot_metrics().revision, before + 2); + } + + #[test] + fn delete_to_empty_preserves_valid_empty_rope() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "hello"); + + let delta = rope.edit(RopeEdit::delete(TextRange::new(0, 5))); + + assert_eq!(delta.removed_text, "hello"); + assert!(rope.is_empty()); + assert_eq!(rope.to_string(), ""); + assert_eq!(rope.snapshot_metrics().leaves, 1); + } + + #[test] + fn structure_changing_edit_updates_leaf_count() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(8) + .push("abcdefgh") + .push("ijklmnop") + .build(); + let before = rope.snapshot_metrics(); + + rope.edit(RopeEdit::insert(8, "QRSTUVWXYZ0123456789")); + let after = rope.snapshot_metrics(); + + assert!(after.leaves >= before.leaves); + assert!(after.text.bytes > before.text.bytes); + } + + #[test] + fn invalid_char_boundary_panics() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "é"); + + let panic = catch_unwind(AssertUnwindSafe(|| { + let _ = rope.slice(TextRange::new(1, 2)); + })); + + assert!(panic.is_err()); + } + + #[test] + fn edit_events_emit_immediately() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abc"); + let seen = Rc::new(RefCell::new(Vec::new())); + let _sub = rope.edit_events().subscribe({ + let seen = Rc::clone(&seen); + move |delta| seen.borrow_mut().push((delta.range, delta.inserted_text.clone())) + }); + + rope.edit(RopeEdit::insert(1, "Z")); + + assert_eq!(&*seen.borrow(), &[(TextRange::new(1, 1), "Z".to_string())]); + } +} diff --git a/lib/reactivity_parsing/src/tokentree.rs b/lib/reactivity_parsing/src/tokentree.rs new file mode 100644 index 0000000..b01ed2f --- /dev/null +++ b/lib/reactivity_parsing/src/tokentree.rs @@ -0,0 +1,2050 @@ +use core::fmt; +use std::cell::RefCell; +use std::rc::Rc; + +use ruin_reactivity::{Cell, Event, Subscription, cell_in, event_in}; + +use crate::{Anchor, AnchorBias, EditDelta, ReactiveRope, TextPoint, TextRange}; + +const SIGILS: &[&str] = &[ + "<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", + "*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|", + "^", "!", "~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$", +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DelimiterKind { + Parenthesis, + Bracket, + Brace, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StringDelimiter { + SingleQuote, + DoubleQuote, + Backtick, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StringFragment { + Text { range: TextRange, text: String }, + Interpolation { range: TextRange, child_index: usize }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumberBase { + Binary, + Octal, + Decimal, + Hexadecimal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumberSuffix { + F32, + F64, + BigFloat, + Decimal128, + BigDecimal, + U8, + U16, + U32, + U64, + U128, + I8, + I16, + I32, + I64, + I128, + BigUint, + BigInt, +} + +impl NumberSuffix { + fn is_integer(self) -> bool { + !matches!( + self, + Self::F32 | Self::F64 | Self::BigFloat | Self::Decimal128 | Self::BigDecimal + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NumberLiteral { + pub raw: String, + pub base: NumberBase, + pub has_radix_point: bool, + pub suffix: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenTreeNodeKind { + Body, + List { + delimiter: DelimiterKind, + terminated: bool, + }, + Symbol { + text: String, + }, + Sigil { + text: String, + }, + String { + delimiter: StringDelimiter, + terminated: bool, + fragments: Vec, + }, + Number(NumberLiteral), + Error { + message: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeNode { + pub id: u64, + pub local_range: TextRange, + pub kind: TokenTreeNodeKind, + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TriviaKind { + LineComment, + BlockComment, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriviaComment { + pub kind: TriviaKind, + pub range: TextRange, + pub text: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TriviaStore { + comments: Vec, +} + +impl TriviaStore { + pub fn comments(&self) -> &[TriviaComment] { + &self.comments + } + + pub fn comments_in_range(&self, range: TextRange) -> Vec<&TriviaComment> { + self.comments + .iter() + .filter(|comment| comment.range.start < range.end && comment.range.end > range.start) + .collect() + } + + pub fn comments_before(&self, offset: usize) -> Vec<&TriviaComment> { + self.comments + .iter() + .filter(|comment| comment.range.end <= offset) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TokenTreeDiagnosticKind { + UnterminatedList, + UnmatchedClosingDelimiter, + UnterminatedString, + UnterminatedInterpolation, + InvalidEscape, + InvalidUnicodeEscape, + UnterminatedBlockComment, + InvalidNumber, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeDiagnostic { + pub kind: TokenTreeDiagnosticKind, + pub range: TextRange, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeParse { + text: String, + pub root_anchor: Anchor, + pub root: TokenTreeNode, + pub diagnostics: Vec, + pub trivia: TriviaStore, + pub stats: TokenTreeStats, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeStats { + pub reused_nodes: usize, + pub total_nodes: usize, + pub reparsed_range: TextRange, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeUpdate { + pub delta: EditDelta, + pub stats: TokenTreeStats, + pub diagnostics_changed: bool, + pub trivia_changed: bool, +} + +pub struct ReactiveTokenTree { + rope: ReactiveRope, + state: Cell, + last_update: Cell>, + updates: Event, + _subscription: Subscription, +} + +impl TokenTreeParse { + pub fn root(&self) -> &TokenTreeNode { + &self.root + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn diagnostics(&self) -> &[TokenTreeDiagnostic] { + &self.diagnostics + } + + pub fn trivia(&self) -> &TriviaStore { + &self.trivia + } + + pub fn stats(&self) -> &TokenTreeStats { + &self.stats + } + + pub fn absolute_range(&self, node_id: u64) -> Option { + fn walk(node: &TokenTreeNode, parent_start: usize, wanted: u64) -> Option { + let range = absolute_range(node, parent_start); + if node.id == wanted { + return Some(range); + } + for child in &node.children { + if let Some(found) = walk(child, range.start, wanted) { + return Some(found); + } + } + None + } + + walk(&self.root, self.root_anchor.byte, node_id) + } + + pub fn absolute_span( + &self, + rope: &ReactiveRope, + node_id: u64, + ) -> Option<(TextPoint, TextPoint)> { + let range = self.absolute_range(node_id)?; + Some((rope.point_at(range.start), rope.point_at(range.end))) + } +} + +impl ReactiveTokenTree { + pub fn new(rope: &ReactiveRope) -> Self { + let reactor = rope.reactor(); + let mut parser = TokenTreeParser::new(); + let initial = parser.parse(rope); + let state = cell_in(&reactor, initial); + let last_update = cell_in(&reactor, None::); + let updates = event_in(&reactor); + let parser = Rc::new(RefCell::new(parser)); + let rope_clone = rope.clone(); + let state_clone = state.clone(); + let last_update_clone = last_update.clone(); + let updates_clone = updates.clone(); + let subscription = rope.edit_events().subscribe({ + let parser = Rc::clone(&parser); + move |delta| { + let previous = state_clone.get(); + let next = parser.borrow_mut().reparse(&rope_clone, &previous, delta); + let update = TokenTreeUpdate { + delta: delta.clone(), + stats: next.stats.clone(), + diagnostics_changed: previous.diagnostics != next.diagnostics, + trivia_changed: previous.trivia != next.trivia, + }; + last_update_clone.replace(Some(update.clone())); + state_clone.replace(next); + updates_clone.emit(update); + } + }); + + Self { + rope: rope.clone(), + state, + last_update, + updates, + _subscription: subscription, + } + } + + pub fn rope(&self) -> ReactiveRope { + self.rope.clone() + } + + pub fn snapshot(&self) -> TokenTreeParse { + self.state.get() + } + + pub fn with_snapshot(&self, f: impl FnOnce(&TokenTreeParse) -> R) -> R { + self.state.with(f) + } + + pub fn last_update(&self) -> Option { + self.last_update.get() + } + + pub fn with_last_update(&self, f: impl FnOnce(&Option) -> R) -> R { + self.last_update.with(f) + } + + pub fn updates(&self) -> Event { + self.updates.clone() + } +} + +#[derive(Default)] +pub struct TokenTreeParser { + next_id: u64, +} + +impl TokenTreeParser { + pub fn new() -> Self { + Self::default() + } + + pub fn parse(&mut self, rope: &ReactiveRope) -> TokenTreeParse { + let text = read_rope_text(rope); + let raw = parse_raw(rope); + let root = self.materialize_fresh(&raw.root, 0); + let total_nodes = count_nodes(&root); + TokenTreeParse { + text, + root_anchor: Anchor::new(0, AnchorBias::Left), + root, + diagnostics: raw.diagnostics, + trivia: raw.trivia, + stats: TokenTreeStats { + reused_nodes: 0, + total_nodes, + reparsed_range: TextRange::new(0, rope.len_bytes()), + }, + } + } + + pub fn reparse( + &mut self, + rope: &ReactiveRope, + previous: &TokenTreeParse, + delta: &EditDelta, + ) -> TokenTreeParse { + let text = read_rope_text(rope); + let raw = parse_raw(rope); + let mut root_anchor = previous.root_anchor; + root_anchor.map_through(delta); + let mut reused_nodes = 0usize; + let root = self.reuse_node( + &raw.root, + Some(&previous.root), + previous.root_anchor.byte, + root_anchor.byte, + &previous.text, + &text, + delta, + &mut reused_nodes, + ); + let total_nodes = count_nodes(&root); + + TokenTreeParse { + text, + root_anchor, + root, + diagnostics: raw.diagnostics, + trivia: raw.trivia, + stats: TokenTreeStats { + reused_nodes, + total_nodes, + reparsed_range: TextRange::new(0, rope.len_bytes()), + }, + } + } + + fn materialize_fresh(&mut self, parsed: &RawNode, parent_start: usize) -> TokenTreeNode { + TokenTreeNode { + id: self.alloc_id(), + local_range: localize(parsed.range, parent_start), + kind: parsed.kind.clone(), + children: parsed + .children + .iter() + .map(|child| self.materialize_fresh(child, parsed.range.start)) + .collect(), + } + } + + fn reuse_node( + &mut self, + parsed: &RawNode, + old: Option<&TokenTreeNode>, + old_parent_start: usize, + new_parent_start: usize, + old_text: &str, + new_text: &str, + delta: &EditDelta, + reused_nodes: &mut usize, + ) -> TokenTreeNode { + let parsed_local = localize(parsed.range, new_parent_start); + if let Some(old) = old.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta)) { + *reused_nodes += count_nodes(old); + return TokenTreeNode { + id: old.id, + local_range: parsed_local, + kind: old.kind.clone(), + children: old.children.clone(), + }; + } + + let children = if let Some(old) = old { + let mut used = vec![false; old.children.len()]; + parsed + .children + .iter() + .enumerate() + .map(|(index, child)| { + let candidate = old + .children + .get(index) + .filter(|candidate| { + !used[index] + && can_descend_into_node(candidate, child) + }) + .map(|candidate| { + used[index] = true; + candidate + }) + .or_else(|| { + old.children.iter().enumerate().find_map(|(candidate_index, candidate)| { + if used[candidate_index] + || !can_descend_into_node(candidate, child) + { + return None; + } + used[candidate_index] = true; + Some(candidate) + }) + }); + + self.reuse_node( + child, + candidate, + absolute_range(old, old_parent_start).start, + parsed.range.start, + old_text, + new_text, + delta, + reused_nodes, + ) + }) + .collect() + } else { + parsed + .children + .iter() + .map(|child| { + self.reuse_node( + child, + None, + old_parent_start, + parsed.range.start, + old_text, + new_text, + delta, + reused_nodes, + ) + }) + .collect() + }; + + TokenTreeNode { + id: self.alloc_id(), + local_range: parsed_local, + kind: parsed.kind.clone(), + children, + } + } + + fn alloc_id(&mut self) -> u64 { + self.next_id = self.next_id.wrapping_add(1); + self.next_id + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RawNode { + range: TextRange, + kind: TokenTreeNodeKind, + children: Vec, +} + +struct RawParse { + root: RawNode, + diagnostics: Vec, + trivia: TriviaStore, +} + +struct Cursor<'a> { + rope: &'a ReactiveRope, + offset: usize, + diagnostics: Vec, + trivia: TriviaStore, +} + +fn parse_raw(rope: &ReactiveRope) -> RawParse { + let mut cursor = Cursor { + rope, + offset: 0, + diagnostics: Vec::new(), + trivia: TriviaStore::default(), + }; + let children = cursor.parse_sequence(None); + let root = RawNode { + range: TextRange::new(0, rope.len_bytes()), + kind: TokenTreeNodeKind::Body, + children, + }; + RawParse { + root, + diagnostics: cursor.diagnostics, + trivia: cursor.trivia, + } +} + +impl<'a> Cursor<'a> { + fn parse_sequence(&mut self, terminator: Option) -> Vec { + let mut nodes = Vec::new(); + let mut terminated = terminator.is_none(); + loop { + self.skip_ws_and_comments(); + if self.is_eof() { + break; + } + + if let Some(close) = terminator { + if self.peek_char() == Some(close) { + self.bump_char(); + terminated = true; + break; + } + } + + let Some(ch) = self.peek_char() else { + break; + }; + if let Some(delimiter) = opening_delimiter(ch) { + nodes.push(self.parse_list(delimiter)); + continue; + } + if matches!(ch, ')' | ']' | '}') { + let start = self.offset; + self.bump_char(); + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + range: TextRange::new(start, self.offset), + message: "unmatched closing delimiter".into(), + }); + nodes.push(RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Error { + message: "unmatched closing delimiter".into(), + }, + children: Vec::new(), + }); + continue; + } + if matches!(ch, '\'' | '"' | '`') { + nodes.push(self.parse_string()); + continue; + } + if ch.is_ascii_digit() { + nodes.push(self.parse_number()); + continue; + } + if let Some(sigil) = self.longest_sigil() { + nodes.push(self.parse_sigil(sigil)); + continue; + } + nodes.push(self.parse_symbol_or_error()); + } + + if let Some(close) = terminator + && !terminated + { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedList, + range: TextRange::new(self.offset, self.offset), + message: format!("unterminated list; expected `{close}`"), + }); + } + nodes + } + + fn parse_list(&mut self, delimiter: DelimiterKind) -> RawNode { + let start = self.offset; + let (_, close) = delimiter_chars(delimiter); + self.bump_char(); + let children = self.parse_sequence(Some(close)); + let terminated = + self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset) == close.to_string(); + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::List { + delimiter, + terminated, + }, + children, + } + } + + fn parse_sigil(&mut self, sigil: &str) -> RawNode { + let start = self.offset; + self.offset += sigil.len(); + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Sigil { + text: sigil.to_string(), + }, + children: Vec::new(), + } + } + + fn parse_symbol_or_error(&mut self) -> RawNode { + let start = self.offset; + let Some(first) = self.peek_char() else { + return RawNode { + range: TextRange::new(start, start), + kind: TokenTreeNodeKind::Error { + message: "unexpected end of input".into(), + }, + children: Vec::new(), + }; + }; + + if !self.is_symbol_start(first) { + self.bump_char(); + return RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Error { + message: format!("unexpected character `{first}`"), + }, + children: Vec::new(), + }; + } + + self.bump_char(); + while let Some(ch) = self.peek_char() { + if !self.is_symbol_continue(ch) { + break; + } + self.bump_char(); + } + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Symbol { + text: self.slice(start, self.offset), + }, + children: Vec::new(), + } + } + + fn parse_string(&mut self) -> RawNode { + let start = self.offset; + let delimiter_char = self.bump_char().expect("string should start with delimiter"); + let delimiter = match delimiter_char { + '\'' => StringDelimiter::SingleQuote, + '"' => StringDelimiter::DoubleQuote, + '`' => StringDelimiter::Backtick, + _ => unreachable!(), + }; + + let mut fragments = Vec::new(); + let mut children = Vec::new(); + let mut text_start = self.offset; + let mut terminated = false; + while let Some(ch) = self.peek_char() { + if ch == delimiter_char { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + self.bump_char(); + terminated = true; + break; + } + + if delimiter == StringDelimiter::Backtick && self.starts_with("$(") { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + let interp_start = self.offset; + self.offset += 2; + let interpolation_children = self.parse_sequence(Some(')')); + let terminated_interp = self + .slice(self.offset.saturating_sub(1), self.offset) + == ")"; + if !terminated_interp { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedInterpolation, + range: TextRange::new(interp_start, self.offset), + message: "unterminated string interpolation".into(), + }); + } + let child_index = children.len(); + children.push(RawNode { + range: TextRange::new(interp_start + 1, self.offset), + kind: TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: terminated_interp, + }, + children: interpolation_children, + }); + fragments.push(StringFragment::Interpolation { + range: TextRange::new(interp_start, self.offset), + child_index, + }); + text_start = self.offset; + continue; + } + + if ch == '\\' { + if let Err((range, kind, message)) = self.consume_escape(delimiter) { + self.diagnostics.push(TokenTreeDiagnostic { + kind, + range, + message: message.clone(), + }); + } + continue; + } + + self.bump_char(); + } + + if !terminated { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedString, + range: TextRange::new(start, self.offset), + message: "unterminated string".into(), + }); + } + + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::String { + delimiter, + terminated, + fragments, + }, + children, + } + } + + fn parse_number(&mut self) -> RawNode { + let start = self.offset; + let (literal, end, maybe_error) = self.scan_number(start); + self.offset = end; + if let Some((kind, message)) = maybe_error { + self.diagnostics.push(TokenTreeDiagnostic { + kind, + range: TextRange::new(start, end), + message: message.clone(), + }); + return RawNode { + range: TextRange::new(start, end), + kind: TokenTreeNodeKind::Error { message }, + children: Vec::new(), + }; + } + RawNode { + range: TextRange::new(start, end), + kind: TokenTreeNodeKind::Number(literal), + children: Vec::new(), + } + } + + fn skip_ws_and_comments(&mut self) { + loop { + while let Some(ch) = self.peek_char() { + if ch.is_whitespace() { + self.bump_char(); + } else { + break; + } + } + + if self.starts_with("//") { + let start = self.offset; + self.offset += 2; + while let Some(ch) = self.peek_char() { + if ch == '\n' { + break; + } + self.bump_char(); + } + self.trivia.comments.push(TriviaComment { + kind: TriviaKind::LineComment, + range: TextRange::new(start, self.offset), + text: self.slice(start, self.offset), + }); + continue; + } + + if self.starts_with("/*") { + let start = self.offset; + self.offset += 2; + let mut depth = 1usize; + while !self.is_eof() { + if self.starts_with("/*") { + depth += 1; + self.offset += 2; + } else if self.starts_with("*/") { + depth -= 1; + self.offset += 2; + if depth == 0 { + break; + } + } else { + self.bump_char(); + } + } + + if depth != 0 { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedBlockComment, + range: TextRange::new(start, self.offset), + message: "unterminated block comment".into(), + }); + } + + self.trivia.comments.push(TriviaComment { + kind: TriviaKind::BlockComment, + range: TextRange::new(start, self.offset), + text: self.slice(start, self.offset), + }); + continue; + } + + break; + } + } + + fn consume_escape( + &mut self, + delimiter: StringDelimiter, + ) -> Result<(), (TextRange, TokenTreeDiagnosticKind, String)> { + let start = self.offset; + self.bump_char(); + match self.bump_char() { + Some('n' | 'r' | 't' | '\\' | '0') => Ok(()), + Some('\'') if delimiter != StringDelimiter::DoubleQuote => Ok(()), + Some('"') if delimiter != StringDelimiter::SingleQuote => Ok(()), + Some('`') if delimiter == StringDelimiter::Backtick => Ok(()), + Some('u') => { + if self.bump_char() != Some('{') { + return Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escapes must be of the form \\u{...}".into(), + )); + } + let digits_start = self.offset; + while let Some(ch) = self.peek_char() { + if ch == '}' { + break; + } + if !ch.is_ascii_hexdigit() { + return Err(( + TextRange::new(start, self.offset + ch.len_utf8()), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escape contains non-hex digit".into(), + )); + } + self.bump_char(); + } + if self.peek_char() != Some('}') || self.offset == digits_start { + return Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escape must contain at least one hex digit and terminate with `}`" + .into(), + )); + } + self.bump_char(); + Ok(()) + } + Some('x') => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidEscape, + "hex escapes are not supported".into(), + )), + Some(other) => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidEscape, + format!("invalid escape `\\{other}`"), + )), + None => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::UnterminatedString, + "unterminated escape".into(), + )), + } + } + + fn is_eof(&self) -> bool { + self.offset >= self.rope.len_bytes() + } + + fn peek_char(&self) -> Option { + self.rope + .cursor(self.offset) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + } + + fn bump_char(&mut self) -> Option { + let ch = self.peek_char()?; + self.offset += ch.len_utf8(); + Some(ch) + } + + fn starts_with(&self, pattern: &str) -> bool { + self.starts_with_at(self.offset, pattern) + } + + fn starts_with_at(&self, offset: usize, pattern: &str) -> bool { + rope_starts_with_at(self.rope, offset, pattern) + } + + fn slice(&self, start: usize, end: usize) -> String { + self.rope.slice(TextRange::new(start, end)) + } + + fn longest_sigil(&self) -> Option<&'static str> { + longest_sigil_at(self.rope, self.offset) + } + + fn is_symbol_start(&self, ch: char) -> bool { + !ch.is_ascii_digit() && self.is_symbol_continue(ch) + } + + fn is_symbol_continue(&self, ch: char) -> bool { + if ch.is_whitespace() || ch.is_control() { + return false; + } + if matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '\'' | '"' | '`') { + return false; + } + if self.starts_with("//") || self.starts_with("/*") { + return false; + } + self.longest_sigil().is_none() + } + + fn scan_number( + &self, + start: usize, + ) -> (NumberLiteral, usize, Option<(TokenTreeDiagnosticKind, String)>) { + let mut cursor = start; + let mut base = NumberBase::Decimal; + if self.starts_with_at(start, "0b") { + base = NumberBase::Binary; + cursor += 2; + } else if self.starts_with_at(start, "0o") { + base = NumberBase::Octal; + cursor += 2; + } else if self.starts_with_at(start, "0d") { + base = NumberBase::Decimal; + cursor += 2; + } else if self.starts_with_at(start, "0x") { + base = NumberBase::Hexadecimal; + cursor += 2; + } + + let mut saw_digit = false; + let mut saw_radix = false; + while let Some(ch) = self.char_at(cursor) { + if ch == '_' { + cursor += 1; + continue; + } + if ch == '.' && base == NumberBase::Decimal && !saw_radix { + saw_radix = true; + cursor += 1; + continue; + } + if is_digit_for_base(ch, base) { + saw_digit = true; + cursor += ch.len_utf8(); + continue; + } + break; + } + + let suffix_start = cursor; + while let Some(ch) = self.char_at(cursor) { + if ch.is_ascii_alphanumeric() { + cursor += ch.len_utf8(); + } else { + break; + } + } + + let raw = self.slice(start, cursor); + let suffix_raw = self.slice(suffix_start, cursor); + let suffix = parse_suffix(&suffix_raw); + let literal = NumberLiteral { + raw: raw.clone(), + base, + has_radix_point: saw_radix, + suffix, + }; + + if !saw_digit { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "number literal must contain digits".into(), + )), + ); + } + if suffix_raw.contains('_') { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "numeric suffix cannot contain underscores".into(), + )), + ); + } + if suffix.is_none() && !suffix_raw.is_empty() { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + format!("unknown numeric suffix `{suffix_raw}`"), + )), + ); + } + if saw_radix && suffix.is_some_and(NumberSuffix::is_integer) { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "integer-suffixed number cannot contain a radix point".into(), + )), + ); + } + if saw_radix && base != NumberBase::Decimal { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "only decimal numbers may contain a radix point".into(), + )), + ); + } + if !number_body_valid(&raw, base, saw_radix, suffix_start - start) { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "invalid digits for numeric base".into(), + )), + ); + } + + (literal, cursor, None) + } + + fn char_at(&self, offset: usize) -> Option { + self.rope + .cursor(offset) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + } +} + +fn delimiter_chars(kind: DelimiterKind) -> (char, char) { + match kind { + DelimiterKind::Parenthesis => ('(', ')'), + DelimiterKind::Bracket => ('[', ']'), + DelimiterKind::Brace => ('{', '}'), + } +} + +fn opening_delimiter(ch: char) -> Option { + match ch { + '(' => Some(DelimiterKind::Parenthesis), + '[' => Some(DelimiterKind::Bracket), + '{' => Some(DelimiterKind::Brace), + _ => None, + } +} + +fn longest_sigil_at(rope: &ReactiveRope, offset: usize) -> Option<&'static str> { + SIGILS + .iter() + .copied() + .find(|sigil| rope_starts_with_at(rope, offset, sigil)) +} + +fn rope_starts_with_at(rope: &ReactiveRope, offset: usize, pattern: &str) -> bool { + let mut cursor = offset; + for expected in pattern.chars() { + let Some(found) = rope + .cursor(cursor) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + else { + return false; + }; + if found != expected { + return false; + } + cursor += found.len_utf8(); + } + true +} + +fn read_rope_text(rope: &ReactiveRope) -> String { + let mut cursor = rope.cursor(0); + let mut text = String::new(); + while let Some(chunk) = cursor.next_chunk() { + text.push_str(chunk.text()); + } + text +} + +fn localize(range: TextRange, parent_start: usize) -> TextRange { + TextRange::new( + range.start.saturating_sub(parent_start), + range.end.saturating_sub(parent_start), + ) +} + +fn absolute_range(node: &TokenTreeNode, parent_start: usize) -> TextRange { + TextRange::new( + parent_start + node.local_range.start, + parent_start + node.local_range.end, + ) +} + +fn map_range(range: TextRange, delta: &EditDelta) -> TextRange { + if range.end <= delta.range.start { + return range; + } + if range.start >= delta.range.end { + let shift = delta.net_byte_delta(); + return TextRange::new( + range.start.saturating_add_signed(shift), + range.end.saturating_add_signed(shift), + ); + } + TextRange::new(usize::MAX, usize::MAX) +} + +fn can_reuse_node( + old: &TokenTreeNode, + old_parent_start: usize, + parsed: &RawNode, + old_text: &str, + new_text: &str, + delta: &EditDelta, +) -> bool { + let old_abs = absolute_range(old, old_parent_start); + old.kind == parsed.kind + && map_range(old_abs, delta) == parsed.range + && old_text.get(old_abs.start..old_abs.end) == new_text.get(parsed.range.start..parsed.range.end) +} + +fn can_descend_into_node(old: &TokenTreeNode, parsed: &RawNode) -> bool { + match (&old.kind, &parsed.kind) { + (TokenTreeNodeKind::Body, TokenTreeNodeKind::Body) => true, + ( + TokenTreeNodeKind::List { delimiter: left, .. }, + TokenTreeNodeKind::List { + delimiter: right, .. + }, + ) => left == right, + ( + TokenTreeNodeKind::String { + delimiter: left, .. + }, + TokenTreeNodeKind::String { + delimiter: right, .. + }, + ) => left == right, + (TokenTreeNodeKind::Symbol { text: left }, TokenTreeNodeKind::Symbol { text: right }) => { + left == right + } + (TokenTreeNodeKind::Sigil { text: left }, TokenTreeNodeKind::Sigil { text: right }) => { + left == right + } + (TokenTreeNodeKind::Number(left), TokenTreeNodeKind::Number(right)) => left == right, + _ => false, + } +} + +fn count_nodes(node: &TokenTreeNode) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} + +fn number_body_valid( + raw: &str, + base: NumberBase, + saw_radix: bool, + suffix_start: usize, +) -> bool { + let mut body = &raw[..suffix_start]; + if matches!(base, NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal) + && body.starts_with("0") + && body.len() >= 2 + && matches!(body.as_bytes()[1], b'b' | b'o' | b'd' | b'x') + { + body = &body[2..]; + } + if body.is_empty() { + return false; + } + for ch in body.chars() { + if ch == '_' || (saw_radix && ch == '.') { + continue; + } + if !is_digit_for_base(ch, base) { + return false; + } + } + true +} + +fn is_digit_for_base(ch: char, base: NumberBase) -> bool { + match base { + NumberBase::Binary => matches!(ch, '0' | '1'), + NumberBase::Octal => matches!(ch, '0'..='7'), + NumberBase::Decimal => ch.is_ascii_digit(), + NumberBase::Hexadecimal => ch.is_ascii_hexdigit(), + } +} + +fn parse_suffix(text: &str) -> Option { + match text { + "" => None, + "f32" => Some(NumberSuffix::F32), + "f64" => Some(NumberSuffix::F64), + "f" => Some(NumberSuffix::BigFloat), + "d128" => Some(NumberSuffix::Decimal128), + "d" => Some(NumberSuffix::BigDecimal), + "u8" => Some(NumberSuffix::U8), + "u16" => Some(NumberSuffix::U16), + "u32" => Some(NumberSuffix::U32), + "u64" => Some(NumberSuffix::U64), + "u128" => Some(NumberSuffix::U128), + "i8" => Some(NumberSuffix::I8), + "i16" => Some(NumberSuffix::I16), + "i32" => Some(NumberSuffix::I32), + "i64" => Some(NumberSuffix::I64), + "i128" => Some(NumberSuffix::I128), + "u" => Some(NumberSuffix::BigUint), + "i" => Some(NumberSuffix::BigInt), + _ => None, + } +} + +impl fmt::Display for TokenTreeNodeKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Body => write!(f, "body"), + Self::List { delimiter, .. } => write!(f, "{delimiter:?}"), + Self::Symbol { text } => write!(f, "symbol({text})"), + Self::Sigil { text } => write!(f, "sigil({text})"), + Self::String { delimiter, .. } => write!(f, "string({delimiter:?})"), + Self::Number(number) => write!(f, "number({})", number.raw), + Self::Error { message } => write!(f, "error({message})"), + } + } +} + +#[cfg(test)] +mod tests { + use ruin_reactivity::Reactor; + + use super::*; + use crate::RopeEdit; + + fn parse_text(text: &str) -> TokenTreeParse { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, text); + TokenTreeParser::new().parse(&rope) + } + + fn parse_with_rope(text: &str) -> (ReactiveRope, TokenTreeParse) { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, text); + let parse = TokenTreeParser::new().parse(&rope); + (rope, parse) + } + + fn reparse_after_edit( + before: &str, + edit: RopeEdit, + ) -> (ReactiveRope, TokenTreeParse, TokenTreeParse, EditDelta) { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, before); + let mut parser = TokenTreeParser::new(); + let first = parser.parse(&rope); + let delta = rope.edit(edit); + let second = parser.reparse(&rope, &first, &delta); + (rope, first, second, delta) + } + + fn node_text<'a>(parse: &'a TokenTreeParse, node: &TokenTreeNode) -> &'a str { + let range = parse.absolute_range(node.id).expect("node should have absolute range"); + &parse.text()[range.start..range.end] + } + + fn symbol_texts(parse: &TokenTreeParse) -> Vec<&str> { + parse.root + .children + .iter() + .filter_map(|node| match &node.kind { + TokenTreeNodeKind::Symbol { text } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + fn sigil_texts(parse: &TokenTreeParse) -> Vec<&str> { + parse.root + .children + .iter() + .filter_map(|node| match &node.kind { + TokenTreeNodeKind::Sigil { text } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + fn find_list_child<'a>(node: &'a TokenTreeNode, delimiter: DelimiterKind) -> &'a TokenTreeNode { + node.children + .iter() + .find(|child| { + matches!( + child.kind, + TokenTreeNodeKind::List { + delimiter: found, + .. + } if found == delimiter + ) + }) + .expect("expected list child") + } + + fn diagnostic_kinds(parse: &TokenTreeParse) -> Vec { + parse.diagnostics.iter().map(|diagnostic| diagnostic.kind).collect() + } + + #[test] + fn empty_input_yields_empty_body() { + let parse = parse_text(""); + assert!(parse.diagnostics.is_empty()); + assert!(parse.root.children.is_empty()); + assert_eq!(parse.root.kind, TokenTreeNodeKind::Body); + } + + #[test] + fn whitespace_only_input_yields_empty_body() { + let parse = parse_text(" \n\t "); + assert!(parse.diagnostics.is_empty()); + assert!(parse.root.children.is_empty()); + assert!(parse.trivia.comments().is_empty()); + } + + #[test] + fn parses_mixed_top_level_sequence_in_order() { + let parse = parse_text("alpha + 12 \"hi\" [beta]"); + assert_eq!(parse.root.children.len(), 5); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Sigil { .. })); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::String { .. })); + assert!(matches!(parse.root.children[4].kind, TokenTreeNodeKind::List { .. })); + } + + #[test] + fn parses_nested_lists() { + let parse = parse_text("{ foo(bar, [baz]) }"); + assert!(parse.diagnostics.is_empty()); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Brace, + .. + } + )); + let outer = &parse.root.children[0]; + assert!(matches!(outer.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + let inner_paren = find_list_child(outer, DelimiterKind::Parenthesis); + assert!(matches!(inner_paren.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(inner_paren.children[1].kind, TokenTreeNodeKind::Sigil { .. })); + let inner_bracket = find_list_child(inner_paren, DelimiterKind::Bracket); + assert!(matches!(inner_bracket.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn parses_empty_lists_for_all_delimiters() { + let parse = parse_text("() [] {}"); + assert_eq!(parse.root.children.len(), 3); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Bracket, + terminated: true + } + )); + assert!(matches!( + parse.root.children[2].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Brace, + terminated: true + } + )); + } + + #[test] + fn unterminated_lists_report_diagnostics_per_delimiter() { + let parse = parse_text("( [ {"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedList, + ] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: false + } + )); + let bracket = find_list_child(&parse.root.children[0], DelimiterKind::Bracket); + assert!(matches!( + bracket.kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Bracket, + terminated: false + } + )); + } + + #[test] + fn unmatched_closing_delimiters_produce_error_nodes_and_continue() { + let parse = parse_text(") foo ]"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + ] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn mismatched_closer_inside_list_becomes_error_child() { + let parse = parse_text("(foo]"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + TokenTreeDiagnosticKind::UnterminatedList, + ] + ); + let list = &parse.root.children[0]; + assert!(matches!( + list.kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: false + } + )); + assert!(matches!(list.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(list.children[1].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn extra_closer_after_valid_list_is_reported_at_top_level() { + let parse = parse_text("(foo))"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnmatchedClosingDelimiter] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::List { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn symbols_accept_unicode_printable_text() { + let parse = parse_text("alpha βeta café"); + assert_eq!(symbol_texts(&parse), vec!["alpha", "βeta", "café"]); + } + + #[test] + fn symbols_can_include_printable_non_sigil_punctuation() { + let parse = parse_text("foo§bar baz_ß"); + assert_eq!(symbol_texts(&parse), vec!["foo§bar", "baz_ß"]); + } + + #[test] + fn symbols_split_around_sigils_and_delimiters() { + let parse = parse_text("left+right(foo)"); + assert_eq!(symbol_texts(&parse), vec!["left", "right"]); + assert_eq!(sigil_texts(&parse), vec!["+"]); + assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::List { .. })); + } + + #[test] + fn numeric_prefix_does_not_start_symbol() { + let parse = parse_text("1abc"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::InvalidNumber] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn every_defined_sigil_parses_as_a_sigil_node() { + for sigil in SIGILS { + let parse = parse_text(sigil); + assert_eq!(parse.root.children.len(), 1, "sigil `{sigil}` should be a single token"); + assert!( + matches!( + &parse.root.children[0].kind, + TokenTreeNodeKind::Sigil { text } if text == sigil + ), + "expected `{sigil}` to parse as sigil" + ); + } + } + + #[test] + fn longest_match_sigils_win() { + let parse = parse_text("a >>= b => c ..= d :: e"); + assert_eq!(sigil_texts(&parse), vec![">>=", "=>", "..=", "::"]); + } + + #[test] + fn comments_take_precedence_over_slash_sigils() { + let parse = parse_text("a// comment\nb/* block */c"); + assert!(sigil_texts(&parse).is_empty()); + assert_eq!(symbol_texts(&parse), vec!["a", "b", "c"]); + assert_eq!(parse.trivia.comments().len(), 2); + } + + #[test] + fn preserves_comments_as_trivia() { + let parse = parse_text("foo // hi\n/* block */ bar"); + assert_eq!(parse.trivia.comments().len(), 2); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn nested_block_comments_are_preserved_as_single_trivia_item() { + let parse = parse_text("foo /* outer /* inner */ done */ bar"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.trivia.comments().len(), 1); + assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment); + assert_eq!(parse.trivia.comments()[0].text, "/* outer /* inner */ done */"); + } + + #[test] + fn unterminated_block_comment_reports_diagnostic_and_trivia() { + let parse = parse_text("foo /* never closes"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnterminatedBlockComment] + ); + assert_eq!(parse.trivia.comments().len(), 1); + assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment); + } + + #[test] + fn trivia_queries_filter_by_range_and_offset() { + let parse = parse_text("foo // hi\nbar /* block */ baz"); + let line = &parse.trivia.comments()[0]; + let block = &parse.trivia.comments()[1]; + let overlapping = parse.trivia.comments_in_range(TextRange::new(line.range.start, block.range.end)); + assert_eq!(overlapping.len(), 2); + let before_block = parse.trivia.comments_before(block.range.start); + assert_eq!(before_block.len(), 1); + assert_eq!(before_block[0].text, "// hi"); + } + + #[test] + fn comments_before_respects_exact_end_boundary() { + let parse = parse_text("foo // one\nbar"); + let comment = &parse.trivia.comments()[0]; + let before_comment_end = parse.trivia.comments_before(comment.range.end); + assert_eq!(before_comment_end.len(), 1); + let before_comment_start = parse.trivia.comments_before(comment.range.start); + assert!(before_comment_start.is_empty()); + } + + #[test] + fn parses_empty_single_and_double_quoted_strings() { + let parse = parse_text("'' \"\""); + assert_eq!(parse.root.children.len(), 2); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::SingleQuote, + terminated: true, + .. + } + )); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::DoubleQuote, + terminated: true, + .. + } + )); + } + + #[test] + fn quoted_strings_collect_text_fragments() { + let parse = parse_text("\"hello\" 'world'"); + match &parse.root.children[0].kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!( + fragments, + &vec![StringFragment::Text { + range: TextRange::new(1, 6), + text: "hello".into(), + }] + ); + } + other => panic!("expected string, got {other:?}"), + } + match &parse.root.children[1].kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!( + fragments, + &vec![StringFragment::Text { + range: TextRange::new(9, 14), + text: "world".into(), + }] + ); + } + other => panic!("expected string, got {other:?}"), + } + } + + #[test] + fn strings_support_valid_common_escapes() { + let parse = parse_text("\"line\\nquote\\\"tab\\t\" '\\'' `tick\\``"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.root.children.len(), 3); + } + + #[test] + fn invalid_escape_sequences_report_diagnostics() { + let parse = parse_text("\"bad\\q\" \"bad\\x41\""); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidEscape, + TokenTreeDiagnosticKind::InvalidEscape, + ] + ); + } + + #[test] + fn unicode_escape_sequences_validate_shape_and_digits() { + let parse = parse_text("\"ok\\u{1f642}\" \"bad\\u{}\" \"bad\\u{zz}\" \"bad\\u1234\""); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + ] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { terminated: true, .. } + )); + } + + #[test] + fn unterminated_strings_report_diagnostic() { + let parse = parse_text("\"unterminated"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnterminatedString] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { + terminated: false, + .. + } + )); + } + + #[test] + fn backtick_strings_support_multiple_interpolations() { + let parse = parse_text("`a $(foo) b $(bar)`"); + let string = &parse.root.children[0]; + match &string.kind { + TokenTreeNodeKind::String { + delimiter: StringDelimiter::Backtick, + terminated: true, + fragments, + } => { + assert_eq!(fragments.len(), 4); + assert_eq!(string.children.len(), 2); + assert!(matches!(fragments[0], StringFragment::Text { .. })); + assert!(matches!(fragments[1], StringFragment::Interpolation { child_index: 0, .. })); + assert!(matches!(fragments[2], StringFragment::Text { .. })); + assert!(matches!(fragments[3], StringFragment::Interpolation { child_index: 1, .. })); + assert!(matches!( + string.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + assert!(matches!( + string.children[1].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + } + other => panic!("expected backtick string, got {other:?}"), + } + } + + #[test] + fn strings_support_interpolation() { + let parse = parse_text("`Hello $(world)`"); + let string = &parse.root.children[0]; + match &string.kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!(fragments.len(), 2); + assert_eq!(string.children.len(), 1); + assert!(matches!(string.children[0].kind, TokenTreeNodeKind::List { .. })); + } + other => panic!("expected string, got {other:?}"), + } + } + + #[test] + fn unterminated_interpolation_reports_both_relevant_diagnostics() { + let parse = parse_text("`Hello $(world`"); + let kinds = diagnostic_kinds(&parse); + assert_eq!( + kinds, + vec![ + TokenTreeDiagnosticKind::UnterminatedString, + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedInterpolation, + TokenTreeDiagnosticKind::UnterminatedString, + ] + ); + let string = &parse.root.children[0]; + assert!(matches!( + string.kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::Backtick, + terminated: false, + .. + } + )); + } + + #[test] + fn parses_numbers_with_supported_bases_and_suffixes() { + let cases = [ + ("0", NumberBase::Decimal, false, None), + ("12.3f64", NumberBase::Decimal, true, Some(NumberSuffix::F64)), + ("0d1_2_3", NumberBase::Decimal, false, None), + ("0b1010u8", NumberBase::Binary, false, Some(NumberSuffix::U8)), + ("0o77i16", NumberBase::Octal, false, Some(NumberSuffix::I16)), + ("0xffu", NumberBase::Hexadecimal, false, Some(NumberSuffix::BigUint)), + ("99d", NumberBase::Decimal, false, Some(NumberSuffix::BigDecimal)), + ("42d128", NumberBase::Decimal, false, Some(NumberSuffix::Decimal128)), + ("5f", NumberBase::Decimal, false, Some(NumberSuffix::BigFloat)), + ]; + + for (text, base, has_radix_point, suffix) in cases { + let parse = parse_text(text); + assert!(parse.diagnostics.is_empty(), "unexpected diagnostics for `{text}`"); + match &parse.root.children[0].kind { + TokenTreeNodeKind::Number(number) => { + assert_eq!(number.raw, text); + assert_eq!(number.base, base); + assert_eq!(number.has_radix_point, has_radix_point); + assert_eq!(number.suffix, suffix); + } + other => panic!("expected number for `{text}`, got {other:?}"), + } + } + } + + #[test] + fn numbers_accept_current_underscore_placements() { + let parse = parse_text("1_2_3 0x_ff 0d12_.3f64 1._5"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.root.children.len(), 4); + assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..)))); + } + + #[test] + fn numbers_reject_integer_suffixes_on_radix_numbers() { + let parse = parse_text("1.0u8"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::InvalidNumber] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn numbers_require_digits_after_base_prefix() { + let parse = parse_text("0x 0b2 0o"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidNumber, + TokenTreeDiagnosticKind::InvalidNumber, + TokenTreeDiagnosticKind::InvalidNumber, + ] + ); + assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. }))); + } + + #[test] + fn numbers_reject_unknown_or_underscored_suffixes() { + let parse = parse_text("12foo 9u_8"); + assert_eq!(diagnostic_kinds(&parse), vec![TokenTreeDiagnosticKind::InvalidNumber]); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn numbers_stop_before_non_numeric_sigils() { + let parse = parse_text("0x1..2"); + assert_eq!(parse.root.children.len(), 3); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::Sigil { ref text } if text == ".." + )); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); + } + + #[test] + fn diagnostics_capture_ranges_and_messages() { + let parse = parse_text("\"bad\\x41\""); + let diagnostic = &parse.diagnostics[0]; + assert_eq!(diagnostic.kind, TokenTreeDiagnosticKind::InvalidEscape); + assert!(diagnostic.range.start < diagnostic.range.end); + assert!(diagnostic.message.contains("hex escapes are not supported")); + } + + #[test] + fn absolute_range_and_span_track_nested_nodes() { + let (rope, parse) = parse_with_rope("{\n foo\n [bar]\n}"); + let outer = &parse.root.children[0]; + let bracket = find_list_child(outer, DelimiterKind::Bracket); + let range = parse.absolute_range(bracket.id).expect("bracket should have range"); + assert_eq!(&parse.text()[range.start..range.end], "[bar]"); + let (start, end) = parse.absolute_span(&rope, bracket.id).expect("span should exist"); + assert_eq!(start.line, 2); + assert_eq!(start.column, 2); + assert_eq!(end.line, 2); + assert_eq!(end.column, 7); + } + + #[test] + fn node_text_uses_derived_absolute_ranges() { + let parse = parse_text("{ alpha beta }"); + let list = &parse.root.children[0]; + let alpha = &list.children[0]; + let beta = &list.children[1]; + assert_eq!(node_text(&parse, alpha), "alpha"); + assert_eq!(node_text(&parse, beta), "beta"); + } + + #[test] + fn reparsing_reuses_shifted_subtrees() { + let (_rope, first, second, _delta) = reparse_after_edit("{ a b }", RopeEdit::insert(2, "long ")); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b") + .id; + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b") + .id; + assert_eq!(original_b, reparsed_b); + } + + #[test] + fn reparsing_preserves_shifted_node_identity_but_updates_span() { + let (rope, first, second, _delta) = + reparse_after_edit("{\n a\n b\n}", RopeEdit::insert(2, "long ")); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b"); + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b"); + assert_eq!(original_b.id, reparsed_b.id); + let first_span = first.absolute_span(&rope, original_b.id).expect("first span should exist"); + let second_span = second.absolute_span(&rope, reparsed_b.id).expect("second span should exist"); + assert_ne!(first_span, second_span); + assert_eq!(second_span.0.line, 2); + } + + #[test] + fn reparsing_reuses_unaffected_siblings_when_one_symbol_changes() { + let (_rope, first, second, _delta) = + reparse_after_edit("{ left right }", RopeEdit::replace(TextRange::new(2, 6), "long")); + let first_list = &first.root.children[0]; + let second_list = &second.root.children[0]; + let old_right = first_list.children[1].id; + let new_right = second_list.children[1].id; + assert_eq!(old_right, new_right); + assert_ne!(first.root.id, second.root.id); + assert_ne!(first_list.id, second_list.id); + assert_ne!(first_list.children[0].id, second_list.children[0].id); + } + + #[test] + fn reparsing_does_not_reuse_node_when_kind_changes() { + let (_rope, first, second, _delta) = + reparse_after_edit("foo", RopeEdit::replace(TextRange::new(0, 3), "123")); + assert!(matches!(first.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(second.root.children[0].kind, TokenTreeNodeKind::Number(..))); + assert_ne!(first.root.children[0].id, second.root.children[0].id); + } + + #[test] + fn reparsing_preserves_nodes_across_comment_and_whitespace_shifts() { + let (_rope, first, second, _delta) = + reparse_after_edit("{ alpha beta }", RopeEdit::insert(2, "/* hi */\n")); + let first_beta = first.root.children[0].children[1].id; + let second_beta = second.root.children[0].children[1].id; + assert_eq!(first_beta, second_beta); + assert_eq!(second.trivia.comments().len(), 1); + } + + #[test] + fn reactive_parser_updates_snapshot_and_reuses_subtrees() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "{ a b }"); + let parser = ReactiveTokenTree::new(&rope); + + let first = parser.snapshot(); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b") + .id; + + let _ = rope.edit(RopeEdit::insert(2, "long ")); + + let second = parser.snapshot(); + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b") + .id; + assert_eq!(original_b, reparsed_b); + let update = parser.last_update().expect("edit should produce update"); + assert!(update.stats.reused_nodes > 0); + } + + #[test] + fn reactive_parser_emits_update_events() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "foo"); + let parser = ReactiveTokenTree::new(&rope); + let seen = Rc::new(RefCell::new(Vec::new())); + let _subscription = parser.updates().subscribe({ + let seen = Rc::clone(&seen); + move |update| seen.borrow_mut().push(update.clone()) + }); + + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, 3), "123")); + + let seen = seen.borrow(); + assert_eq!(seen.len(), 1); + assert!(seen[0].diagnostics_changed || seen[0].stats.total_nodes > 0); + } + +} diff --git a/lib/ruin_app/src/app.rs b/lib/ruin_app/src/app.rs index 613f5a6..1251811 100644 --- a/lib/ruin_app/src/app.rs +++ b/lib/ruin_app/src/app.rs @@ -14,7 +14,9 @@ use ruin_ui::{ use ruin_ui_platform_wayland::start_wayland_ui; use crate::Result; -use crate::context::{RenderState, render_with_context}; +use crate::context::{ + InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction, +}; use crate::input::{ EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag, TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer, @@ -58,6 +60,11 @@ impl Window { self.spec = self.spec.base_color(color); self } + + pub fn resizable(mut self, resizable: bool) -> Self { + self.spec.resizable = resizable; + self + } } impl Default for Window { @@ -160,7 +167,10 @@ impl MountedApp { let shortcuts = Rc::new(RefCell::new(Vec::::new())); let current_title = Rc::new(RefCell::new(None::)); let last_min_size = Rc::new(RefCell::new(None::)); + let last_fixed_size = Rc::new(RefCell::new(None::)); let explicit_min_size = app_window.spec.min_inner_size; + let explicit_max_size = app_window.spec.max_inner_size; + let window_resizable = app_window.spec.resizable; let mut input_state = InputState::new(); let mut pointer_router = PointerRouter::new(); @@ -174,18 +184,31 @@ impl MountedApp { let shortcuts = Rc::clone(&shortcuts); let current_title = Rc::clone(¤t_title); let last_min_size = Rc::clone(&last_min_size); + let last_fixed_size = Rc::clone(&last_fixed_size); let root = Rc::clone(&root); let render_state = Rc::clone(&render_state); let text_selection = Rc::clone(&input_state.text_selection); + let hovered_elements = input_state.hovered_elements.clone(); + let pressed_elements = input_state.pressed_elements.clone(); + let interaction_version = input_state.interaction_version.clone(); move || { let viewport = viewport.get(); let version = scene_version.get().wrapping_add(1); scene_version.set(version); let _ = text_selection.version.get(); + let _ = interaction_version.get(); let t_effect = Instant::now(); + let interaction_snapshot = Rc::new(InteractionSnapshot { + hovered: hovered_elements.get(), + pressed: pressed_elements.get(), + }); - let render_output = render_with_context(Rc::clone(&render_state), || root.render()); + let render_output = render_with_context_and_interaction( + Rc::clone(&render_state), + interaction_snapshot, + || root.render(), + ); if render_output.side_effects.window_title != *current_title.borrow() { if let Some(title) = &render_output.side_effects.window_title { @@ -211,7 +234,6 @@ impl MountedApp { ); let layout_us = t_layout.elapsed().as_micros(); - // Compute and send min-size hint: max of layout min and explicit window min. let desired_min = UiSize::new( root_min_size .width @@ -220,10 +242,31 @@ impl MountedApp { .height .max(explicit_min_size.map_or(0.0, |s| s.height)), ); - let current_min = *last_min_size.borrow(); - if current_min != Some(desired_min) { - let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min))); - *last_min_size.borrow_mut() = Some(desired_min); + if window_resizable { + let current_min = *last_min_size.borrow(); + if current_min != Some(desired_min) { + let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min))); + *last_min_size.borrow_mut() = Some(desired_min); + } + } else { + let desired_fixed = UiSize::new( + desired_min + .width + .min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)), + desired_min + .height + .min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)), + ); + let current_fixed = *last_fixed_size.borrow(); + if current_fixed != Some(desired_fixed) { + let _ = window.update( + WindowUpdate::new() + .requested_inner_size(Some(desired_fixed)) + .min_inner_size(Some(desired_fixed)) + .max_inner_size(Some(desired_fixed)), + ); + *last_fixed_size.borrow_mut() = Some(desired_fixed); + } } let effect_us = t_effect.elapsed().as_micros(); @@ -355,10 +398,15 @@ impl MountedApp { .last() .map(|target| target.cursor) .unwrap_or(ruin_ui::CursorIcon::Default); + let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets()); + let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets()); if next_cursor != input_state.current_cursor { input_state.current_cursor = next_cursor; window.set_cursor_icon(next_cursor)?; } + if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) { + input_state.interaction_version.update(|value| *value += 1); + } Ok(()) } @@ -458,6 +506,32 @@ impl MountedApp { Ok(()) } + + fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec { + targets.iter().filter_map(|target| target.element_id).collect() + } + + fn update_interaction_state( + input_state: &mut InputState, + hovered_ids: Vec, + pressed_ids: Vec, + ) -> bool { + let previous_hovered = input_state.hovered_elements.get(); + let previous_pressed = input_state.pressed_elements.get(); + let hovered_changed = input_state.hovered_elements.set(hovered_ids.clone()).is_some(); + let pressed_changed = input_state.pressed_elements.set(pressed_ids.clone()).is_some(); + if hovered_changed || pressed_changed { + tracing::trace!( + target: "ruin_app::interaction", + hovered_before = ?previous_hovered, + hovered_after = ?hovered_ids, + pressed_before = ?previous_pressed, + pressed_after = ?pressed_ids, + "interaction state changed" + ); + } + hovered_changed || pressed_changed + } } #[doc(hidden)] diff --git a/lib/ruin_app/src/context.rs b/lib/ruin_app/src/context.rs index 619d6e7..ef884e7 100644 --- a/lib/ruin_app/src/context.rs +++ b/lib/ruin_app/src/context.rs @@ -29,6 +29,12 @@ pub(crate) struct RenderState { pub(crate) next_element_id: StdCell, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct InteractionSnapshot { + pub(crate) hovered: Vec, + pub(crate) pressed: Vec, +} + #[derive(Clone, Default)] pub(crate) struct RenderSideEffects { pub(crate) window_title: Option, @@ -42,6 +48,7 @@ pub(crate) struct RenderContext { pub(crate) element_index: Rc>, pub(crate) side_effects: Rc>, pub(crate) context_entries: Rc>, + pub(crate) interaction: Rc, } impl RenderContext { @@ -52,6 +59,7 @@ impl RenderContext { element_index: Rc::clone(&self.element_index), side_effects: Rc::clone(&self.side_effects), context_entries, + interaction: Rc::clone(&self.interaction), } } } @@ -68,6 +76,14 @@ thread_local! { pub(crate) fn render_with_context( state: Rc, render: impl FnOnce() -> View, +) -> RenderOutput { + render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render) +} + +pub(crate) fn render_with_context_and_interaction( + state: Rc, + interaction: Rc, + render: impl FnOnce() -> View, ) -> RenderOutput { let context = RenderContext { state, @@ -75,6 +91,7 @@ pub(crate) fn render_with_context( element_index: Rc::new(StdCell::new(0)), side_effects: Rc::new(RefCell::new(RenderSideEffects::default())), context_entries: Rc::new(Vec::new()), + interaction, }; let view = with_render_context(context.clone(), render); diff --git a/lib/ruin_app/src/hooks.rs b/lib/ruin_app/src/hooks.rs index e87c533..f719e9d 100644 --- a/lib/ruin_app/src/hooks.rs +++ b/lib/ruin_app/src/hooks.rs @@ -155,6 +155,11 @@ pub fn use_window_title(compute: impl FnOnce() -> String) { pub enum Key { Character(char), Enter, + Backspace, + Escape, + Delete, + ArrowLeft, + ArrowRight, ArrowUp, ArrowDown, Home, @@ -169,6 +174,11 @@ impl Key { .next() .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)), (Self::Enter, KeyboardKey::Enter) => true, + (Self::Backspace, KeyboardKey::Backspace) => true, + (Self::Escape, KeyboardKey::Escape) => true, + (Self::Delete, KeyboardKey::Delete) => true, + (Self::ArrowLeft, KeyboardKey::ArrowLeft) => true, + (Self::ArrowRight, KeyboardKey::ArrowRight) => true, (Self::ArrowUp, KeyboardKey::ArrowUp) => true, (Self::ArrowDown, KeyboardKey::ArrowDown) => true, (Self::Home, KeyboardKey::Home) => true, @@ -209,12 +219,27 @@ impl Shortcut { } pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool { - event.kind == KeyboardEventKind::Pressed - && self.key.matches(event) - && event.modifiers.control == self.control - && event.modifiers.shift == self.shift - && event.modifiers.alt == self.alt - && event.modifiers.super_key == self.super_key + if event.kind != KeyboardEventKind::Pressed { + return false; + } + if !self.key.matches(event) { + return false; + } + if event.modifiers.control != self.control { + return false; + } + if event.modifiers.alt != self.alt { + return false; + } + if event.modifiers.super_key != self.super_key { + return false; + } + // For character shortcuts the produced character already encodes the shift + // state, so don't additionally require that Shift is (un)held. + if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift { + return false; + } + true } } @@ -223,6 +248,12 @@ pub struct FocusScope { pub(crate) element_id: Signal>, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct InteractionState { + pub hovered: bool, + pub pressed: bool, +} + #[derive(Clone)] pub enum ShortcutScope { Application, @@ -296,6 +327,18 @@ pub fn use_widget_ref() -> WidgetRef { }) } +pub fn use_interaction_state(widget_ref: WidgetRef) -> InteractionState { + with_render_context_state(|context| { + let Some(element_id) = widget_ref.element_id() else { + return InteractionState::default(); + }; + InteractionState { + hovered: context.interaction.hovered.contains(&element_id), + pressed: context.interaction.pressed.contains(&element_id), + } + }) +} + pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) { use_shortcut_with_context(shortcut, scope, move |_| action()); } @@ -371,4 +414,3 @@ where |slot: &mut ResourceSlot| slot.resource.clone(), ) } - diff --git a/lib/ruin_app/src/input.rs b/lib/ruin_app/src/input.rs index 5e04d09..0d6eff7 100644 --- a/lib/ruin_app/src/input.rs +++ b/lib/ruin_app/src/input.rs @@ -124,6 +124,9 @@ pub(crate) struct InputState { pub(crate) current_cursor: CursorIcon, pub(crate) focused_element: Option, pub(crate) text_selection: Rc, + pub(crate) hovered_elements: Signal>, + pub(crate) pressed_elements: Signal>, + pub(crate) interaction_version: Signal, } impl InputState { @@ -132,6 +135,9 @@ impl InputState { current_cursor: CursorIcon::Default, focused_element: None, text_selection: Rc::new(TextSelectionState::new()), + hovered_elements: Signal::new(Vec::new()), + pressed_elements: Signal::new(Vec::new()), + interaction_version: Signal::new(0), } } } diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 86febdd..6f22d2b 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -23,9 +23,10 @@ pub mod view; pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test}; pub use converters::{IntoBorder, IntoEdges, IntoShadow}; pub use hooks::{ - BlockWidget, FocusScope, Key, Memo, Resource, ResourceState, ScrollBoxWidget, Shortcut, - ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect, use_memo, use_resource, - use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, + BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState, + ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context, + use_effect, use_interaction_state, use_memo, use_resource, use_shortcut, + use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, }; pub use primitives::{ ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder, @@ -59,19 +60,20 @@ pub type Result = std::result::Result>; pub mod prelude { pub use crate::{ App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder, - ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, - Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, - Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, - WidgetRef, Window, block, button, colors, column, component, context_provider, provide, - row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, - use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, - view, + ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView, + Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, + ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, + TextValue, View, WidgetRef, Window, block, button, colors, column, component, + context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect, + use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context, + use_signal, use_widget_ref, use_window_title, view, }; pub use ruin_ui::{ AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize, }; + pub use crate::PartialTextStyle; } #[cfg(test)] @@ -84,7 +86,9 @@ mod tests { use ruin_ui::{KeyboardModifiers, Point, WindowSpec}; use super::*; - use crate::context::{RenderState, render_with_context}; + use crate::context::{ + InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction, + }; use crate::input::{ InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event, }; @@ -177,6 +181,53 @@ mod tests { assert!(debug.contains("body child"), "{debug}"); } + #[test] + fn interaction_state_tracks_hover_and_press_for_widget_refs() { + let render_state = Rc::new(RenderState::default()); + let hovered = Rc::new(RefCell::new(None::)); + let pressed = Rc::new(RefCell::new(None::)); + let widget_ref_slot = Rc::new(RefCell::new(None::>)); + + let _ = render_with_context(Rc::clone(&render_state), { + let hovered = Rc::clone(&hovered); + let pressed = Rc::clone(&pressed); + let widget_ref_slot = Rc::clone(&widget_ref_slot); + move || { + let widget_ref = use_widget_ref::(); + *hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered); + *pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed); + *widget_ref_slot.borrow_mut() = Some(widget_ref.clone()); + block().widget_ref(widget_ref).children(()) + } + }); + + let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set"); + let element_id = widget_ref + .element_id() + .expect("widget ref should have an element id after render"); + let _ = render_with_context_and_interaction( + render_state, + Rc::new(InteractionSnapshot { + hovered: vec![element_id], + pressed: vec![element_id], + }), + { + let hovered = Rc::clone(&hovered); + let pressed = Rc::clone(&pressed); + let widget_ref_slot = Rc::clone(&widget_ref_slot); + move || { + let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist"); + *hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered); + *pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed); + block().widget_ref(widget_ref).children(()) + } + }, + ); + + assert_eq!(*hovered.borrow(), Some(true)); + assert_eq!(*pressed.borrow(), Some(true)); + } + #[test] fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() { use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey}; diff --git a/lib/ruin_app/src/primitives/button.rs b/lib/ruin_app/src/primitives/button.rs index 3d669a3..3ec44ad 100644 --- a/lib/ruin_app/src/primitives/button.rs +++ b/lib/ruin_app/src/primitives/button.rs @@ -31,6 +31,11 @@ pub fn button() -> ButtonBuilder { impl ButtonBuilder { impl_props_methods!(); + pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self { + self.props = self.props.shadow(shadow); + self + } + pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self { self.on_press = Some(Rc::new(handler)); self @@ -44,6 +49,9 @@ impl ButtonBuilder { pub fn children(mut self, children: impl Children) -> View { let id = allocate_element_id(); self.props.element = self.props.element.id(id); + if let Some(widget_ref) = &self.props.widget_ref { + let _ = widget_ref.set(Some(id)); + } let had_style = self.props.partial_text_style.is_some(); if let Some(style) = self.props.partial_text_style.take() { push_text_style(style); diff --git a/lib/ruin_app/src/primitives/container.rs b/lib/ruin_app/src/primitives/container.rs index 148f71f..fb6a2bb 100644 --- a/lib/ruin_app/src/primitives/container.rs +++ b/lib/ruin_app/src/primitives/container.rs @@ -128,16 +128,6 @@ pub fn block() -> ContainerBuilder { impl ContainerBuilder { impl_props_methods!(); - pub fn align_items(mut self, align: AlignItems) -> Self { - self.props = self.props.align_items(align); - self - } - - pub fn align_self(mut self, align: AlignItems) -> Self { - self.props = self.props.align_self(align); - self - } - pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { self.props = self.props.shadow(shadow); self diff --git a/lib/ruin_app/src/primitives/mod.rs b/lib/ruin_app/src/primitives/mod.rs index acfa8aa..406570a 100644 --- a/lib/ruin_app/src/primitives/mod.rs +++ b/lib/ruin_app/src/primitives/mod.rs @@ -45,6 +45,14 @@ macro_rules! impl_props_methods { self.props = self.props.flex(flex); self } + pub fn align_items(mut self, align: ruin_ui::AlignItems) -> Self { + self.props = self.props.align_items(align); + self + } + pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self { + self.props = self.props.align_self(align); + self + } pub fn widget_ref(mut self, widget_ref: $crate::hooks::WidgetRef) -> Self { self.props = self.props.widget_ref(widget_ref); self diff --git a/lib/runtime/README.md b/lib/runtime/README.md index e69de29..ad9266d 100644 --- a/lib/runtime/README.md +++ b/lib/runtime/README.md @@ -0,0 +1,4 @@ +# RUIN - Runtime + +The RUIN runtime is an event-loop-per-thread, non-workstealing async executor implemented on top of Linux io_uring +with \ No newline at end of file diff --git a/lib/runtime/src/fs.rs b/lib/runtime/src/fs.rs index 8b8514d..d4c070b 100644 --- a/lib/runtime/src/fs.rs +++ b/lib/runtime/src/fs.rs @@ -8,11 +8,12 @@ //! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using //! async methods for operations that may block the caller. +use alloc::sync::Arc; + use std::ffi::OsStr; use std::io; use std::os::fd::{AsRawFd, OwnedFd}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use crate::op::fs::{ FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index ca2aad5..0f96abd 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -44,6 +44,7 @@ pub mod net; pub mod op; #[doc(hidden)] pub mod platform; +pub mod stdio; #[doc(hidden)] pub mod sys; pub mod time; diff --git a/lib/runtime/src/net.rs b/lib/runtime/src/net.rs index 7902f53..51eb936 100644 --- a/lib/runtime/src/net.rs +++ b/lib/runtime/src/net.rs @@ -3,14 +3,15 @@ //! The public surface follows the general shape of `std::net`, but uses async methods for socket //! operations that would otherwise block the caller. -use std::future::Future; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use core::time::Duration; + use std::io; use std::net::{Shutdown, SocketAddr, ToSocketAddrs}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; -use std::pin::Pin; use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll}; -use std::time::Duration; use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite}; diff --git a/lib/runtime/src/platform/linux_x86_64/driver.rs b/lib/runtime/src/platform/linux_x86_64/driver.rs index 344ae9a..a4e79db 100644 --- a/lib/runtime/src/platform/linux_x86_64/driver.rs +++ b/lib/runtime/src/platform/linux_x86_64/driver.rs @@ -1,4 +1,6 @@ -//! Public runtime driver primitives. +//! RUIN Runtime Driver for Linux x86_64. +//! +//! use std::cell::Cell; use std::cell::RefCell; @@ -88,12 +90,21 @@ pub struct ReadyEvents { /// Low-level Linux runtime driver backed by `io_uring`. pub struct Driver { + /// The `io_uring` instance driving this runtime thread. ring: IoUring, + /// Shared notifier that other threads can use to wake this runtime thread. notifier: Arc, + /// Next sequence number for generated completion tokens. next_token: Cell, + /// The token of the currently active timer, if any timer is armed. active_timer_token: Cell>, + /// Accumulated count of pending wake notifications that have not yet been triggered. pending_wakes: Cell, + /// Accumulated count of pending timer expirations that have not yet been triggered. pending_timers: Cell, + /// Map of active completion tokens to associated handlers. When a CQE is received with a token in this map, the + /// corresponding handler will be invoked with the CQE and removed from the map. This is the core mechanism by which + /// async operations are tracked and dispatched to their continuations. completions: RefCell>, } diff --git a/lib/runtime/src/stdio.rs b/lib/runtime/src/stdio.rs new file mode 100644 index 0000000..26004f4 --- /dev/null +++ b/lib/runtime/src/stdio.rs @@ -0,0 +1,191 @@ +//! Async standard-input helpers. + +use std::cell::Cell; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::thread; + +use crate::op::completion::completion_for_current_thread; +use crate::platform::linux_x86_64::runtime::with_current_driver; +use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe}; + +thread_local! { + static STDIN_URING_SUPPORTED: Cell> = const { Cell::new(None) }; +} + +const FILE_CURSOR: u64 = u64::MAX; +const READ_CHUNK_BYTES: usize = 1024; + +/// Async line-oriented stdin reader. +/// +/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module +/// falls back to a helper-thread blocking read on the same duplicated fd. +pub struct Stdin { + fd: OwnedFd, + buffer: Vec, +} + +/// Opens an async stdin reader. +pub fn stdin() -> io::Result { + let raw = cvt(unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 0) })?; + Ok(Stdin { + fd: unsafe { OwnedFd::from_raw_fd(raw) }, + buffer: Vec::new(), + }) +} + +impl Stdin { + /// Reads a single UTF-8 line, including the trailing newline when present. + /// + /// Returns `Ok(None)` on EOF. + pub async fn read_line(&mut self) -> io::Result> { + loop { + if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') { + let line = self.buffer.drain(..=index).collect::>(); + return decode_line(line).map(Some); + } + + let chunk = self.read_chunk().await?; + if chunk.is_empty() { + if self.buffer.is_empty() { + return Ok(None); + } + let line = std::mem::take(&mut self.buffer); + return decode_line(line).map(Some); + } + + self.buffer.extend_from_slice(&chunk); + } + } + + async fn read_chunk(&self) -> io::Result> { + let fd = self.fd.as_raw_fd(); + let support = STDIN_URING_SUPPORTED.with(Cell::get); + if support != Some(false) { + match submit_uring_read(fd, READ_CHUNK_BYTES).await { + Ok(bytes) => { + STDIN_URING_SUPPORTED.with(|state| state.set(Some(true))); + return Ok(bytes); + } + Err(error) if should_fallback_to_offload(&error) => { + STDIN_URING_SUPPORTED.with(|state| state.set(Some(false))); + } + Err(error) => return Err(error), + } + } + + offload(move || { + let mut buffer = vec![0; READ_CHUNK_BYTES]; + let read = blocking_read(fd, &mut buffer)?; + if read == 0 { + buffer.clear(); + } else { + buffer.truncate(read); + } + Ok(buffer) + }) + .await + } +} + +async fn offload( + task: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + let (future, handle) = completion_for_current_thread::>(); + thread::Builder::new() + .name("ruin-runtime-stdio-offload".into()) + .spawn(move || handle.complete(task())) + .map_err(io::Error::other)?; + future.await +} + +async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result> { + let mut buffer = vec![0; len]; + let ptr = buffer.as_mut_ptr(); + let capacity = buffer.len(); + submit_uring( + move |sqe| { + sqe.opcode = IORING_OP_READ; + sqe.fd = fd; + sqe.addr = ptr as u64; + sqe.len = capacity as u32; + sqe.off = FILE_CURSOR; + }, + move |cqe| { + let read = cqe_to_result(cqe)? as usize; + buffer.truncate(read); + Ok(buffer) + }, + ) + .await +} + +async fn submit_uring( + fill: impl FnOnce(&mut IoUringSqe), + map: M, +) -> io::Result +where + M: FnOnce(IoUringCqe) -> io::Result + Send + 'static, +{ + let (future, handle) = completion_for_current_thread::>(); + let callback_handle = handle.clone(); + let token = with_current_driver(|driver| { + driver.submit_operation(fill, move |cqe| { + callback_handle.complete(map(cqe)); + }) + })?; + + handle.set_cancel(move || { + let _ = with_current_driver(|driver| driver.cancel_operation(token)); + }); + + future.await +} + +fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result { + loop { + let read = unsafe { + libc::read( + fd, + buffer.as_mut_ptr().cast::(), + buffer.len(), + ) + }; + if read >= 0 { + return Ok(read as usize); + } + + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } +} + +fn decode_line(bytes: Vec) -> io::Result { + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn cqe_to_result(cqe: IoUringCqe) -> io::Result { + if cqe.res < 0 { + Err(io::Error::from_raw_os_error(-cqe.res)) + } else { + Ok(cqe.res) + } +} + +fn should_fallback_to_offload(error: &io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(libc::EINVAL | libc::ENOSYS | libc::EOPNOTSUPP) + ) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} diff --git a/lib/runtime/src/time.rs b/lib/runtime/src/time.rs index 9e8d519..dcaacc9 100644 --- a/lib/runtime/src/time.rs +++ b/lib/runtime/src/time.rs @@ -3,14 +3,14 @@ //! These helpers integrate with the runtime's timer queue and are designed to be used from //! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros. -use std::cell::{Cell, RefCell}; -use std::fmt; -use std::future::{Future, poll_fn}; -use std::pin::Pin; -use std::rc::Rc; -use std::task::Waker; -use std::task::{Context, Poll}; -use std::time::Duration; +use alloc::rc::Rc; +use core::cell::{Cell, RefCell}; +use core::fmt; +use core::future::{Future, poll_fn}; +use core::pin::Pin; +use core::task::Waker; +use core::task::{Context, Poll}; +use core::time::Duration; use crate::{clear_timeout, set_timeout}; diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 0370daa..eedfb70 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -54,7 +54,7 @@ pub struct RoutedPointerEvent { #[derive(Default)] pub struct PointerRouter { hovered: Vec, - pressed: Option, + pressed: Option>, } impl PointerRouter { @@ -71,7 +71,11 @@ impl PointerRouter { } pub fn pressed_target(&self) -> Option<&HitTarget> { - self.pressed.as_ref() + self.pressed.as_ref().and_then(|targets| targets.last()) + } + + pub fn pressed_targets(&self) -> &[HitTarget] { + self.pressed.as_deref().unwrap_or(&[]) } pub fn route( @@ -114,7 +118,12 @@ impl PointerRouter { match event.kind { PointerEventKind::Move => { - if let Some(target) = self.pressed.clone().or(hit_target.clone()) { + if let Some(target) = self + .pressed + .as_ref() + .and_then(|targets| targets.last().cloned()) + .or(hit_target.clone()) + { routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Move, target, @@ -125,7 +134,7 @@ impl PointerRouter { } PointerEventKind::Down { button } => { if let Some(target) = hit_target { - self.pressed = Some(target.clone()); + self.pressed = Some(self.hovered.clone()); routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Down { button }, target, @@ -135,7 +144,12 @@ impl PointerRouter { } } PointerEventKind::Up { button } => { - if let Some(target) = self.pressed.take().or(hit_target) { + if let Some(target) = self + .pressed + .take() + .and_then(|targets| targets.last().cloned()) + .or(hit_target) + { routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Up { button }, target, diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 30ce8b8..b3928fc 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -148,6 +148,7 @@ enum InternalBackendEvent { struct State { running: bool, + fixed_size_request: Option<(u32, u32)>, _connection: Connection, _compositor: wl_compositor::WlCompositor, _surface: wl_surface::WlSurface, @@ -232,6 +233,18 @@ impl State { } } +fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> { + if spec.resizable { + return None; + } + let size = spec + .requested_inner_size + .or(spec.min_inner_size) + .or(spec.max_inner_size) + .unwrap_or_else(|| UiSize::new(800.0, 500.0)); + Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32)) +} + fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape { match icon { CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default, @@ -364,6 +377,7 @@ impl WaylandWindow { surface_target, state: State { running: true, + fixed_size_request: fixed_size_request_for_spec(&spec), _connection: connection, _compositor: compositor, _surface: surface, @@ -697,6 +711,7 @@ impl WaylandWindow { } pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box> { + self.state.fixed_size_request = fixed_size_request_for_spec(spec); self.state._toplevel.set_title(spec.title.clone()); if let Some(app_id) = spec.app_id.as_ref() { self.state._toplevel.set_app_id(app_id.clone()); @@ -732,6 +747,14 @@ impl WaylandWindow { } if let Some((width, height)) = self.state.pending_size.take() { + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + previous_width = self.state.current_size.0, + previous_height = self.state.current_size.1, + "prepare_frame observed pending size change" + ); self.state.current_size = (width, height); self.state.needs_redraw = false; return Some(FrameRequest { @@ -742,6 +765,12 @@ impl WaylandWindow { } if self.state.needs_redraw { + trace!( + target: "ruin_ui_platform_wayland::resize", + width = self.state.current_size.0, + height = self.state.current_size.1, + "prepare_frame servicing redraw without size change" + ); self.state.needs_redraw = false; return Some(FrameRequest { width: self.state.current_size.0, @@ -2101,6 +2130,14 @@ impl Dispatch for State { if let xdg_surface::Event::Configure { serial } = event { xdg_surface.ack_configure(serial); state.configured = true; + trace!( + target: "ruin_ui_platform_wayland::resize", + serial, + current_width = state.current_size.0, + current_height = state.current_size.1, + has_pending_size = state.pending_size.is_some(), + "received xdg_surface configure" + ); state.request_redraw(); } } @@ -2153,7 +2190,37 @@ impl Dispatch for State { height, "received Wayland toplevel configure" ); - state.pending_size = Some((width, height)); + let next_size = (width, height); + if let Some(expected_size) = state.fixed_size_request { + if next_size != expected_size { + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + expected_width = expected_size.0, + expected_height = expected_size.1, + "ignoring stale toplevel configure for fixed-size window" + ); + state.request_redraw(); + return; + } + } + let size_changed = + next_size != state.current_size && state.pending_size != Some(next_size); + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + current_width = state.current_size.0, + current_height = state.current_size.1, + pending_width = state.pending_size.map(|size| size.0), + pending_height = state.pending_size.map(|size| size.1), + size_changed, + "processed Wayland toplevel configure" + ); + if size_changed { + state.pending_size = Some(next_size); + } state.request_redraw(); } _ => {}