Lots of owork on a calculator example, transparency, etc.

This commit is contained in:
2026-05-16 16:18:55 -04:00
parent e2c2563b7e
commit 6dcc2d0d00
40 changed files with 5783 additions and 125 deletions

View File

@@ -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" }

View File

@@ -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<String>,
}
#[derive(Clone, Debug)]
struct AstNode {
id: u64,
kind: JsonKind,
local_range: TextRange,
children: Vec<AstNode>,
}
#[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<ParsedNode>,
}
#[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 -- <path/to/file.json>");
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::<EditDelta>);
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 <text>` 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 <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
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 <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
}
Ok(Command::Quit) => break,
Err(error) => eprintln!("{error}"),
}
}
}
fn parse_command(input: &str) -> Result<Command, String> {
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 <offset> <text>".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 <start> <end> <text>".to_string())?;
let end = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let text = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".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 <start> <end>".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<ParseSnapshot, String> {
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<ParseSnapshot, String> {
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<ParsedNode, String> {
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<char> {
self.text[self.offset..].chars().next()
}
fn bump_char(&mut self) -> Option<char> {
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<ParsedNode, String> {
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<ParsedNode, String> {
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<ParsedNode, String> {
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<ParsedNode, String> {
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<ParsedNode, String> {
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<ParsedNode, String> {
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::<usize>()
}

View File

@@ -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 -- <path/to/file>");
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 <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
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 <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
}
Ok(Command::Quit) => break,
Err(error) => eprintln!("{error}"),
}
}
}
fn parse_command(input: &str) -> Result<Command, String> {
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 <offset> <text>".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 <start> <end> <text>".to_string())?;
let end = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let text = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".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 <start> <end>".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);
}
}

View File

@@ -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<String>) -> 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<String>) -> 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);
}
}

View File

@@ -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,
};

View File

@@ -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);
}
}

View File

@@ -0,0 +1,162 @@
use core::fmt;
use crate::{RopeCursor, TextRange};
pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {}
impl<T> ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SyntaxFragment<C, T, D> {
pub range: TextRange,
pub start_checkpoint: C,
pub end_checkpoint: C,
pub tree: T,
pub diagnostics: Vec<D>,
pub grammar_revision: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseTree<T> {
pub root: T,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseInvalidation<C> {
pub resume_at: usize,
pub restart_checkpoint: C,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseResult<C, T, D> {
pub tree: ParseTree<T>,
pub fragments: Vec<SyntaxFragment<C, T, D>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseError {
pub message: String,
}
impl ParseError {
pub fn new(message: impl Into<String>) -> 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<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
) -> ParseInvalidation<Self::Checkpoint>;
fn parse(
&self,
cursor: RopeCursor,
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, 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<Cell<usize>>,
}
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<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
) -> ParseInvalidation<Self::Checkpoint> {
ParseInvalidation {
resume_at: delta_range.start.saturating_sub(1),
restart_checkpoint: 1,
}
}
fn parse(
&self,
mut cursor: RopeCursor,
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, 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);
}
}

View File

@@ -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<RopeInner>,
}
pub struct RopeBuilder {
reactor: Reactor,
leaf_target_bytes: usize,
chunks: Vec<String>,
}
#[derive(Clone)]
pub struct RopeChunk {
text: Rc<str>,
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<u64>,
leaf_target_bytes: usize,
leaves: RefCell<Vec<Rc<Leaf>>>,
edit_events: Event<EditDelta>,
}
struct Leaf {
source: Source,
text: RefCell<Rc<str>>,
metrics: Cell<TextMetrics>,
tail_chars: Cell<usize>,
}
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<String>) -> 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<str>) -> Self {
RopeBuilder::new(reactor).push(text.as_ref()).build()
}
fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec<String>) -> 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<EditDelta> {
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<RopeChunk> {
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<Item = RopeEdit>) -> Vec<EditDelta> {
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<Rc<Leaf>>,
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<RopeChunk> {
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<RopeChunk> {
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<str> = 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<str> {
self.source.observe();
self.text.borrow().clone()
}
fn set_text(&self, text: String) {
let text: Rc<str> = 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())]);
}
}

File diff suppressed because it is too large Load Diff