Files
Ely-GPUI-Components/src/documents/document/block.rs
T
ZacharyZhang-NYandClaude Opus 5.5 ce015e7b7b documents: a caret inside CRLF reads as its line's end
The code editor splits lines on LF only, so End and Right can leave the
caret between CR and LF. line_start and line_end now read that spot as
the end of its line, so insert_table keeps CRLF around a new table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:44:59 -04:00

455 lines
15 KiB
Rust

//! Block commands at the caret, as Typora's Paragraph menu (R4.7).
mod check;
mod heading;
mod insert;
mod list;
mod quote;
mod split;
use std::ops::Range;
use markdown::mdast::Node;
use super::format::{Change, EditRefusal};
use crate::documents::markdown::{file_tree, span, walk};
/// A command of Typora's Paragraph menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockCommand {
/// Levels 1 to 6.
Heading(u8),
Paragraph,
/// A level up; a paragraph turns level 6.
Promote,
/// A level down; level 6 turns a paragraph.
Demote,
Quote,
OrderedList,
BulletList,
TaskList,
Rule,
CodeBlock,
MathBlock,
Toc,
FrontMatter,
Footnote,
LinkDefinition,
ParagraphBefore,
ParagraphAfter,
Delete,
}
/// `command` at `selection` in `text`, or why not.
pub(super) fn run(
text: &str,
selection: Range<usize>,
command: BlockCommand,
) -> Result<Change, EditRefusal> {
let root = file_tree(text);
let here = Here::new(text, &root, selection);
match command {
BlockCommand::Heading(_)
| BlockCommand::Paragraph
| BlockCommand::Promote
| BlockCommand::Demote => heading::run(&here, command),
BlockCommand::Quote => quote::run(&here),
BlockCommand::OrderedList | BlockCommand::BulletList | BlockCommand::TaskList => {
list::run(&here, command)
}
BlockCommand::FrontMatter => insert::front_matter(&here),
BlockCommand::ParagraphBefore => insert::paragraph(&here, true),
BlockCommand::ParagraphAfter => insert::paragraph(&here, false),
BlockCommand::Delete => insert::delete(&here),
_ => insert::run(&here, command),
}
}
/// The caret's place in the parsed text.
pub(super) struct Here<'a> {
pub(super) text: &'a str,
pub(super) selection: Range<usize>,
/// Root down to the deepest block at the selection's start.
pub(super) path: Vec<&'a Node>,
pub(super) root: &'a Node,
/// Every node, in document order.
pub(super) nodes: Vec<&'a Node>,
}
impl<'a> Here<'a> {
fn new(text: &'a str, root: &'a Node, selection: Range<usize>) -> Self {
let mut nodes = Vec::new();
walk(root, &mut nodes);
Here {
text,
path: path(text, root, selection.start),
selection,
root,
nodes,
}
}
/// The caret's block, unless the path ends on a container.
pub(super) fn leaf(&self) -> Option<&'a Node> {
let last = *self.path.last()?;
(!holds(last)).then_some(last)
}
/// The containers around `path[..upto]`.
pub(super) fn containers(&self, upto: usize) -> Vec<&'a Node> {
self.path[..upto]
.iter()
.copied()
.filter(|node| container(node))
.collect()
}
/// Whether the caret's line holds nothing past its prefix.
pub(super) fn blank(&self) -> bool {
let line = line_start(self.text, self.selection.start);
let containers = self.containers(self.path.len());
inside(self.text, line, &containers)
.is_some_and(|at| self.text[at..line_end(self.text, line)].trim().is_empty())
}
/// Root down to the deepest block at `at`.
pub(super) fn path_at(&self, at: usize) -> Vec<&'a Node> {
path(self.text, self.root, at)
}
/// The common holder's index and the blocks the selection touches.
pub(super) fn blocks(&self) -> Option<(usize, Vec<&'a Node>)> {
let end = self.path_at(self.selection.end);
let mut k = 0;
while k + 1 < self.path.len()
&& k + 1 < end.len()
&& std::ptr::eq(self.path[k + 1], end[k + 1])
{
k += 1;
}
if !holds(self.path[k]) {
return Some((k - 1, vec![self.path[k]]));
}
let (s, e) = (self.selection.start, self.selection.end);
let kids: Vec<&Node> = (self.path[k].children().map(Vec::as_slice))
.unwrap_or_default()
.iter()
.filter(|kid| span(kid).start <= e && s <= span(kid).end)
.collect();
(!kids.is_empty()).then_some((k, kids))
}
/// Whether a paragraph ending on the line before joins `block`.
pub(super) fn joins_before(&self, block: &Range<usize>, gone: &[&Node]) -> bool {
let line = line_start(self.text, block.start);
let Some(previous) = prev_line(self.text, line) else {
return false;
};
let ended = (self.nodes.iter()).any(|node| {
matches!(node, Node::Paragraph(_)) && line_start(self.text, span(node).end) == previous
});
ended && !self.opens(line, gone)
}
/// Whether the line after `block` would join it as text.
pub(super) fn joins_after(&self, block: &Range<usize>) -> bool {
let Some(next) = next_line(self.text, block.end) else {
return false;
};
let starts = (self.nodes.iter()).any(|node| {
let at = span(node).start;
line_start(self.text, at) == next
&& match node {
Node::Paragraph(_) => true,
Node::ThematicBreak(_) => self.text[at..].starts_with('-'),
_ => false,
}
});
starts && !self.opens(next, &[])
}
/// Whether `line` opens a container that stays and interrupts.
fn opens(&self, line: usize, gone: &[&Node]) -> bool {
let on = |node: &Node| line_start(self.text, span(node).start) == line;
let left = |node: &Node| !gone.iter().any(|gone| std::ptr::eq(*gone, node));
(self.nodes.iter()).any(|node| match node {
Node::List(list) if left(node) => list
.children
.iter()
.any(|item| on(item) && (!list.ordered || list.start == Some(1))),
Node::Blockquote(_) => on(node) && left(node),
_ => false,
})
}
}
/// Blockquotes, list items and footnote definitions.
pub(super) fn container(node: &Node) -> bool {
matches!(
node,
Node::Blockquote(_) | Node::ListItem(_) | Node::FootnoteDefinition(_)
)
}
/// Whether `node` is task item `holder`'s first text.
pub(super) fn task_head(holder: &Node, node: &Node) -> bool {
matches!(holder, Node::ListItem(item)
if item.checked.is_some()
&& item.children.first().is_some_and(|kid| std::ptr::eq(kid, node)))
}
/// Where a heading-closing `#` run starts in `line`.
pub(super) fn closing(line: &str) -> Option<usize> {
let line = line.trim_end_matches([' ', '\t']);
let kept = line.trim_end_matches('#');
(kept.len() < line.len() && kept.ends_with([' ', '\t'])).then_some(kept.len())
}
/// The byte to escape so `line` reads as prose.
pub(super) fn unblocked(line: &str) -> Result<Option<usize>, EditRefusal> {
let prose = |line: &str| {
let root = file_tree(line);
matches!(
root.children().and_then(|kids| kids.first()),
Some(Node::Paragraph(_))
)
};
if prose(line) {
return Ok(None);
}
let mark = line.find(|ch: char| ch.is_ascii_punctuation());
match mark.filter(|mark| prose(&format!("{}\\{}", &line[..*mark], &line[*mark..]))) {
Some(mark) => Ok(Some(mark)),
None => Err(check::misread("a line stays a block")),
}
}
/// Whether the path goes on below `node`.
fn holds(node: &Node) -> bool {
container(node) || matches!(node, Node::Root(_) | Node::List(_))
}
/// Root down to the deepest block at `at`.
fn path<'a>(text: &str, root: &'a Node, at: usize) -> Vec<&'a Node> {
let mut path = vec![root];
while let Some(node) = path.last().copied().filter(|node| holds(node)) {
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
let found = (kids.iter())
.find(|kid| span(kid).contains(&at) || span(kid).end == at)
.or_else(|| {
// A caret before a block on its line is inside.
(kids.iter()).find(|kid| {
let start = span(kid).start;
at < start && !text[at..start].contains(['\n', '\r'])
})
});
let Some(kid) = found else {
break;
};
path.push(kid);
}
path
}
/// `at`, before the CR when it splits a CRLF.
fn settled(text: &str, at: usize) -> usize {
let split = text[..at].ends_with('\r') && text[at..].starts_with('\n');
if split { at - 1 } else { at }
}
pub(super) fn line_start(text: &str, at: usize) -> usize {
let at = settled(text, at);
text[..at].rfind(['\n', '\r']).map_or(0, |at| at + 1)
}
pub(super) fn line_end(text: &str, at: usize) -> usize {
let at = settled(text, at);
at + text[at..].find(['\n', '\r']).unwrap_or(text.len() - at)
}
/// The start of the line after `at`'s.
pub(super) fn next_line(text: &str, at: usize) -> Option<usize> {
let end = line_end(text, at);
let rest = &text[end..];
let ending = if rest.starts_with("\r\n") { 2 } else { 1 };
(end < text.len()).then_some(end + ending)
}
/// The start of the line before `at`'s.
pub(super) fn prev_line(text: &str, at: usize) -> Option<usize> {
let start = line_start(text, at);
let ending = if text[..start].ends_with("\r\n") {
2
} else {
1
};
(start > 0).then(|| line_start(text, start - ending))
}
/// The text's own line ending.
pub(super) fn eol(text: &str) -> &'static str {
match (text.contains("\r\n"), text.contains('\r')) {
(true, _) => "\r\n",
(false, true) => "\r",
_ => "\n",
}
}
/// Columns before `at` on its line, tabs stopping at four.
pub(super) fn column(text: &str, at: usize) -> usize {
advance(0, &text[line_start(text, at)..at])
}
fn advance(col: usize, run: &str) -> usize {
run.chars().fold(col, |col, ch| match ch {
'\t' => col / 4 * 4 + 4,
_ => col + 1,
})
}
/// A list marker's or footnote label's bytes, then its spaces.
pub(super) fn marker(text: &str, node: &Node) -> (usize, usize) {
let start = span(node).start;
let line = &text[start..line_end(text, start)];
let mark = match node {
Node::FootnoteDefinition(_) => line.find("]:").map_or(line.len(), |at| at + 2),
_ if line.starts_with(['-', '*', '+']) => 1,
_ => line.find(['.', ')']).map_or(line.len(), |at| at + 1),
};
let after = &line[mark..];
let spaces = after.len() - after.trim_start_matches([' ', '\t']).len();
(mark, spaces)
}
/// The column a container's continuation lines start at.
pub(super) fn indent(text: &str, node: &Node) -> usize {
let start = span(node).start;
let base = column(text, start);
let (mark, spaces) = marker(text, node);
if matches!(node, Node::FootnoteDefinition(_)) {
return base + 4;
}
let after = advance(base + mark, &text[start + mark..start + mark + spaces]) - base - mark;
let blank = text[start + mark..line_end(text, start)].trim().is_empty();
match blank || !(1..=4).contains(&after) {
true => base + mark + 1,
false => base + mark + after,
}
}
/// Where a container's text starts on its first line.
pub(super) fn opener(text: &str, node: &Node) -> usize {
let start = span(node).start;
let (mark, spaces) = marker(text, node);
let width = advance(
column(text, start + mark),
&text[start + mark..start + mark + spaces],
) - column(text, start + mark);
match (1..=4).contains(&width) {
true => start + mark + spaces,
false => start + mark + spaces.min(1),
}
}
/// Where `line`'s text starts inside `containers`; none when lazy.
pub(super) fn inside(text: &str, line: usize, containers: &[&Node]) -> Option<usize> {
let end = line_end(text, line);
let mut at = line;
for node in containers {
if text[at..end].trim().is_empty() {
return Some(end);
}
if matches!(node, Node::Blockquote(_)) {
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
if lead > 3 || !text[at + lead..end].starts_with('>') {
return None;
}
at += lead + 1;
at += usize::from(text[at..end].starts_with([' ', '\t']));
continue;
}
let start = span(node).start;
if line <= start && start < end {
at = opener(text, node);
continue;
}
let want = indent(text, node);
let mut col = column(text, at);
while col < want {
let ch = text[at..end]
.chars()
.next()
.filter(|ch| matches!(ch, ' ' | '\t'))?;
col = advance(col, &text[at..at + 1]);
at += ch.len_utf8();
}
}
Some(at)
}
/// The prefix a new line takes inside `containers`.
pub(super) fn prefix(text: &str, containers: &[&Node]) -> String {
let mut out = String::new();
for (ix, node) in containers.iter().enumerate() {
if matches!(node, Node::Blockquote(_)) {
out.push_str("> ");
continue;
}
let start = span(node).start;
let outer = inside(text, line_start(text, start), &containers[..ix]).unwrap_or(start);
let width = indent(text, node).saturating_sub(column(text, outer.min(start)));
out.push_str(&" ".repeat(width));
}
out
}
/// Where byte `at` lies once `edits`, sorted, are written.
pub(super) fn shifted(at: usize, edits: &[(Range<usize>, String)]) -> usize {
let mut moved = at;
for (range, with) in edits {
if range.end <= at {
moved = moved + with.len() - range.len();
} else if range.start < at {
moved -= at - range.start;
}
}
moved
}
/// `edits`, sorted, and the caret mapped through them.
pub(super) fn change(mut edits: Vec<(Range<usize>, String)>, caret: Range<usize>) -> Change {
edits.sort_by_key(|(range, _)| range.start);
assert!(
edits
.windows(2)
.all(|pair| pair[0].0.end <= pair[1].0.start),
"block edits overlap"
);
let caret = shifted(caret.start, &edits)..shifted(caret.end, &edits);
(edits, caret)
}
/// `edits`, sorted, with the caret already where it lands.
pub(super) fn placed(mut edits: Vec<(Range<usize>, String)>, caret: usize) -> Change {
edits.sort_by_key(|(range, _)| range.start);
assert!(
edits
.windows(2)
.all(|pair| pair[0].0.end <= pair[1].0.start),
"block edits overlap"
);
(edits, caret..caret)
}
/// Why `node` takes no block command at the caret.
pub(super) fn refusal(node: &Node) -> EditRefusal {
match node {
Node::Code(_) => EditRefusal::Code,
Node::Math(_) => EditRefusal::Math,
Node::Html(_) => EditRefusal::Html,
Node::Table(_) => EditRefusal::Table,
Node::Yaml(_) | Node::Toml(_) => EditRefusal::FrontMatter,
_ => EditRefusal::Outside,
}
}