documents: block commands keep text, tasks and list items whole
Round-1 fixes for R4.7: a caret in heading marks splits at the text; a split closes and reopens bold, italic and strike, refusing inside links, code, math and HTML; a task's first text takes no block; retype keeps task boxes and code indent and takes siblings past the list; an alert in an item keeps the item's opener; paragraph-before moves a holder's first text below its opener; delete keeps neighbors from joining; a heading's text keeps closing hashes and block openers escaped; a heading level is idempotent, as Typora's menu calls it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
58e212d4ec
commit
b37031b00b
9 files changed
+438
-153
No files matched your search
@@ -4,6 +4,7 @@ mod heading;
|
||||
mod insert;
|
||||
mod list;
|
||||
mod quote;
|
||||
mod split;
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
@@ -189,6 +190,13 @@ pub(super) fn container(node: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
/// Whether the path goes on below `node`.
|
||||
fn holds(node: &Node) -> bool {
|
||||
container(node) || matches!(node, Node::Root(_) | Node::List(_))
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::{
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
markdown::{file_tree, span},
|
||||
};
|
||||
|
||||
/// The level `command` asks at `level`; 0 is a paragraph.
|
||||
@@ -18,7 +18,7 @@ fn target(level: u8, command: BlockCommand) -> u8 {
|
||||
match command {
|
||||
BlockCommand::Heading(n) => {
|
||||
assert!((1..=6).contains(&n), "a heading level is 1 to 6");
|
||||
if n == level { 0 } else { n }
|
||||
n
|
||||
}
|
||||
BlockCommand::Promote if level == 0 => 6,
|
||||
BlockCommand::Promote => (level - 1).max(1),
|
||||
@@ -61,7 +61,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
let edits = match (level, goal) {
|
||||
(0, _) if task_head(here, &whole) => return Err(EditRefusal::Outside),
|
||||
(0, _) => to_heading(here, &containers, &whole, hashes),
|
||||
(_, 0) => to_paragraph(here, node, &containers),
|
||||
(_, 0) => to_paragraph(here, node, &containers)?,
|
||||
_ => relevel(here, node, &containers, goal),
|
||||
};
|
||||
Ok(change(edits, caret))
|
||||
@@ -75,8 +75,38 @@ fn atx(text: &str, node: &Node) -> bool {
|
||||
/// Whether the caret is on a task item's first line.
|
||||
fn task_head(here: &Here, whole: &Range<usize>) -> bool {
|
||||
let first = line_start(here.text, whole.start) == line_start(here.text, here.selection.start);
|
||||
matches!(here.path.iter().rev().nth(1), Some(Node::ListItem(item)) if item.checked.is_some())
|
||||
&& first
|
||||
let leaf = here.path[here.path.len() - 1];
|
||||
first && super::task_head(here.path[here.path.len() - 2], leaf)
|
||||
}
|
||||
|
||||
/// Where a `#` run that would close the heading starts.
|
||||
fn closing(text: &str, at: usize) -> Option<usize> {
|
||||
let line = text[at..line_end(text, at)].trim_end_matches([' ', '\t']);
|
||||
let kept = line.trim_end_matches('#');
|
||||
(kept.len() < line.len() && kept.ends_with([' ', '\t'])).then(|| at + kept.len())
|
||||
}
|
||||
|
||||
/// The byte to escape if `at`'s line would open a block.
|
||||
fn opener(text: &str, at: usize) -> Result<Option<usize>, EditRefusal> {
|
||||
let line = &text[at..line_end(text, at)];
|
||||
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(at + mark)),
|
||||
None => {
|
||||
log::warn!("block command: a heading's text stays a block");
|
||||
Err(EditRefusal::Outside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's line led by `hashes`; lazy lines kept in.
|
||||
@@ -105,6 +135,9 @@ fn to_heading(
|
||||
};
|
||||
if line == first {
|
||||
edits.push((at..at, format!("{lazy}{hashes}")));
|
||||
if let Some(run) = closing(text, at) {
|
||||
edits.push((run..run, "\\".to_string()));
|
||||
}
|
||||
} else if !lazy.is_empty() {
|
||||
edits.push((at..at, lazy.to_string()));
|
||||
}
|
||||
@@ -117,7 +150,11 @@ fn to_heading(
|
||||
}
|
||||
|
||||
/// A heading's marks off; a blank line where text joins.
|
||||
fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<usize>, String)> {
|
||||
fn to_paragraph(
|
||||
here: &Here,
|
||||
node: &Node,
|
||||
containers: &[&Node],
|
||||
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text));
|
||||
@@ -130,7 +167,11 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<us
|
||||
match atx(text, node) {
|
||||
true => match (kids.first(), kids.last()) {
|
||||
(Some(first), Some(last)) => {
|
||||
edits.push((whole.start..span(first).start, String::new()));
|
||||
let start = span(first).start;
|
||||
edits.push((whole.start..start, String::new()));
|
||||
if let Some(mark) = opener(text, start)? {
|
||||
edits.push((mark..mark, "\\".to_string()));
|
||||
}
|
||||
edits.push((span(last).end..whole.end, String::new()));
|
||||
}
|
||||
_ => edits.push((whole.clone(), String::new())),
|
||||
@@ -146,7 +187,7 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<us
|
||||
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||
edits.push((next..next, blank.clone()));
|
||||
}
|
||||
edits
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// A heading moved to level `goal`.
|
||||
|
||||
@@ -7,51 +7,20 @@ use markdown::mdast::Node;
|
||||
use super::{
|
||||
BlockCommand, Here, container, eol, inside, line_end, line_start, next_line, placed, prefix,
|
||||
prev_line, refusal,
|
||||
split::{Room, content, split},
|
||||
task_head,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
/// Where `line`'s text starts inside `containers`; a lazy line's own.
|
||||
fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
|
||||
let at = inside(text, line, containers).unwrap_or(line);
|
||||
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
|
||||
}
|
||||
|
||||
/// Whether `line` holds text past `containers`' prefix.
|
||||
fn text_on(text: &str, line: usize, containers: &[&Node]) -> bool {
|
||||
let at = content(text, line, containers);
|
||||
at < line_end(text, line)
|
||||
}
|
||||
|
||||
/// Back from `at` over blanks and prefixes, to `floor`.
|
||||
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let line = line_start(text, at).max(floor);
|
||||
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
|
||||
if line == floor || at > content(text, line, containers) {
|
||||
return at;
|
||||
}
|
||||
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
|
||||
}
|
||||
}
|
||||
|
||||
/// On from `at` over blanks and prefixes, to `ceil`.
|
||||
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let end = line_end(text, at).min(ceil);
|
||||
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
|
||||
if at < end {
|
||||
return at;
|
||||
}
|
||||
match next_line(text, at) {
|
||||
Some(next) if next < ceil => at = content(text, next, containers),
|
||||
_ => return ceil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection's text, line by line, prefixes cut.
|
||||
fn picked(here: &Here, containers: &[&Node]) -> Vec<String> {
|
||||
let text = here.text;
|
||||
@@ -165,21 +134,6 @@ fn splits(text: &str, node: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the new block's text goes, ending the edit.
|
||||
struct Room<'a> {
|
||||
nl: &'static str,
|
||||
lead: String,
|
||||
blank: String,
|
||||
containers: Vec<&'a Node>,
|
||||
}
|
||||
|
||||
impl Room<'_> {
|
||||
/// A blank line, then a new line's prefix.
|
||||
fn gap(&self) -> String {
|
||||
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let leaf = here.leaf();
|
||||
@@ -199,7 +153,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
if here.selection.end > span(node).end {
|
||||
return Err(EditRefusal::Across);
|
||||
}
|
||||
Ok(split(here, node, &room, &block, inner))
|
||||
split(here, node, &room, &block, inner)
|
||||
}
|
||||
Some(node) if !here.selection.is_empty() => Err(refusal(node)),
|
||||
Some(node) => {
|
||||
@@ -251,60 +205,6 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
}
|
||||
}
|
||||
|
||||
/// `block` put where the caret splits `node`.
|
||||
fn split(here: &Here, node: &Node, room: &Room, block: &str, inner: Option<usize>) -> Change {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let (marks, body) = match node {
|
||||
Node::Heading(heading) => (
|
||||
"#".repeat(usize::from(heading.depth)) + " ",
|
||||
kids.first().map_or(whole.end, |kid| span(kid).start)
|
||||
..kids.last().map_or(whole.end, |kid| span(kid).end),
|
||||
),
|
||||
_ => (String::new(), whole.clone()),
|
||||
};
|
||||
let containers = &room.containers;
|
||||
let mut from = back(text, here.selection.start, body.start, containers);
|
||||
let after = forth(text, here.selection.end, body.end, containers);
|
||||
let before = from > body.start;
|
||||
let follows = after < body.end;
|
||||
if !before {
|
||||
from = whole.start;
|
||||
}
|
||||
let to = if follows { after } else { whole.end };
|
||||
let gap = room.gap();
|
||||
let mut written = String::new();
|
||||
if before {
|
||||
written.push_str(&gap);
|
||||
}
|
||||
let start = written.len();
|
||||
written.push_str(block);
|
||||
let caret = match inner {
|
||||
Some(inner) => start + inner,
|
||||
None => {
|
||||
written.push_str(&gap);
|
||||
written.len() + if follows { marks.len() } else { 0 }
|
||||
}
|
||||
};
|
||||
if follows {
|
||||
if inner.is_some() {
|
||||
written.push_str(&gap);
|
||||
}
|
||||
written.push_str(&marks);
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
let mut shift = 0;
|
||||
if !before && here.joins_before(&whole, None) {
|
||||
let line = line_start(text, whole.start);
|
||||
let blank = format!("{}{}", room.blank, room.nl);
|
||||
shift = blank.len();
|
||||
edits.push((line..line, blank));
|
||||
}
|
||||
edits.push((from..to, written));
|
||||
placed(edits, from + shift + caret)
|
||||
}
|
||||
|
||||
/// An empty line before or after the caret's block.
|
||||
pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
@@ -319,6 +219,19 @@ pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal
|
||||
containers,
|
||||
};
|
||||
let gap = room.gap();
|
||||
let holder = here.path[here.path.len() - 2];
|
||||
if before && task_head(holder, leaf) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
// A holder's first line keeps its opener: text moves down.
|
||||
if before
|
||||
&& container(holder)
|
||||
&& !matches!(holder, Node::Blockquote(_))
|
||||
&& line_start(text, span(holder).start) == line_start(text, whole.start)
|
||||
{
|
||||
let down = format!("{}{}", room.nl, room.lead);
|
||||
return Ok(placed(vec![(whole.start..whole.start, down)], whole.start));
|
||||
}
|
||||
if before {
|
||||
let mut edits = Vec::new();
|
||||
let mut shift = 0;
|
||||
@@ -387,5 +300,11 @@ pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
|
||||
if blank_above && end < text.len() && !text_on(text, end, &outer) {
|
||||
end = next_line(text, end).unwrap_or(text.len());
|
||||
}
|
||||
Ok(placed(vec![(line..end, String::new())], line))
|
||||
// Neighbors that would join keep a blank line between.
|
||||
let joins = here.joins_before(&whole, None) && here.joins_after(&whole);
|
||||
let with = match joins {
|
||||
true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)),
|
||||
false => String::new(),
|
||||
};
|
||||
Ok(placed(vec![(line..end, with)], line))
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use markdown::mdast::{List, Node};
|
||||
|
||||
use super::{
|
||||
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
|
||||
prefix,
|
||||
opener, prefix,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
@@ -54,12 +54,22 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
}),
|
||||
None => Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))),
|
||||
};
|
||||
// A selection past the list takes every touched sibling.
|
||||
if let Some(ix) = item
|
||||
&& let Node::List(list) = path[ix - 1]
|
||||
&& (here.path_at(caret.end).iter()).any(|node| std::ptr::eq(*node, path[ix - 1]))
|
||||
{
|
||||
let edits = match kind(list) == command {
|
||||
true => unwrap(here, ix - 1, list),
|
||||
false => retype(here, ix - 1, list, command),
|
||||
false => {
|
||||
let start = if list.ordered {
|
||||
list.start.unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let outer = here.containers(ix - 1);
|
||||
retype(here.text, path[ix - 1], list, &outer, command, start)
|
||||
}
|
||||
};
|
||||
return Ok(change(edits, caret));
|
||||
}
|
||||
@@ -160,21 +170,18 @@ fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Every item's marker as `kind`'s, its lines moved to match.
|
||||
fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(ix);
|
||||
let start = if list.ordered {
|
||||
list.start.unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
/// Every item's marker as `kind`'s from `start`, lines moved to match.
|
||||
fn retype(
|
||||
text: &str,
|
||||
node: &Node,
|
||||
list: &List,
|
||||
outer: &[&Node],
|
||||
kind: BlockCommand,
|
||||
start: u32,
|
||||
) -> Vec<(Range<usize>, String)> {
|
||||
let bullet = match list.ordered {
|
||||
true => '-',
|
||||
false => text[span(here.path[ix]).start..]
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('-'),
|
||||
false => text[span(node).start..].chars().next().unwrap_or('-'),
|
||||
};
|
||||
let mut edits = Vec::new();
|
||||
for (n, item) in list.children.iter().enumerate() {
|
||||
@@ -183,13 +190,19 @@ fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range
|
||||
let (mark, spaces) = super::marker(text, item);
|
||||
let first = item.children().and_then(|kids| kids.first()).map(span);
|
||||
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||
let end = match first {
|
||||
Some(kid) if boxed && !text[at..kid.start].contains(['\n', '\r']) => kid.start,
|
||||
_ => at + mark + spaces,
|
||||
let inline = first.filter(|kid| !text[at..kid.start].contains(['\n', '\r']));
|
||||
// A box stays when the kind keeps boxes.
|
||||
let (end, written) = match inline {
|
||||
Some(_) if boxed && kind == BlockCommand::TaskList => {
|
||||
(at + mark + spaces, format!("{bullet} "))
|
||||
}
|
||||
Some(kid) if boxed => (kid.start, marker(kind, number, bullet)),
|
||||
// Spaces past the marker's own are the text's.
|
||||
_ => (opener(text, item), marker(kind, number, bullet)),
|
||||
};
|
||||
edits.push((at..end, marker(kind, number, bullet)));
|
||||
edits.push((at..end, written));
|
||||
let want = column(text, at) + width(kind, number);
|
||||
for (from, cut, past) in continued(text, item, &outer) {
|
||||
for (from, cut, past) in continued(text, item, outer) {
|
||||
let pad = want.saturating_sub(column(text, from)) + past;
|
||||
edits.push((from..cut, " ".repeat(pad)));
|
||||
}
|
||||
@@ -202,7 +215,7 @@ fn wrap(
|
||||
here: &Here,
|
||||
k: usize,
|
||||
blocks: &[&Node],
|
||||
kind: BlockCommand,
|
||||
command: BlockCommand,
|
||||
) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(k + 1);
|
||||
@@ -210,6 +223,13 @@ fn wrap(
|
||||
let mut edits = Vec::new();
|
||||
let mut n = 1;
|
||||
for block in blocks {
|
||||
if let Node::List(list) = block {
|
||||
if kind(list) != command {
|
||||
edits.extend(retype(text, block, list, &outer, command, n));
|
||||
}
|
||||
n += list.children.len() as u32;
|
||||
continue;
|
||||
}
|
||||
let whole = span(block);
|
||||
let mut line = line_start(text, whole.start);
|
||||
while line <= whole.end {
|
||||
@@ -227,9 +247,9 @@ fn wrap(
|
||||
let mark = match first || matches!(block, Node::Paragraph(_)) {
|
||||
true => {
|
||||
n += 1;
|
||||
marker(kind, n - 1, '-')
|
||||
marker(command, n - 1, '-')
|
||||
}
|
||||
false => " ".repeat(width(kind, n - 1)),
|
||||
false => " ".repeat(width(command, n - 1)),
|
||||
};
|
||||
let at = if matches!(block, Node::Paragraph(_)) {
|
||||
content
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix};
|
||||
use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix, task_head};
|
||||
use crate::documents::{
|
||||
alert,
|
||||
document::format::{Change, EditRefusal},
|
||||
@@ -32,6 +32,10 @@ pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
|
||||
return Ok(change(vec![(at..at, "> ".to_string())], caret));
|
||||
}
|
||||
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
|
||||
// GFM reads a task's first line as text.
|
||||
if task_head(here.path[k], blocks[0]) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
Ok(change(wrap(here, k, &blocks), caret))
|
||||
}
|
||||
|
||||
@@ -65,20 +69,21 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
|
||||
if let Node::Blockquote(inner) = quote
|
||||
&& alert(inner).is_some()
|
||||
{
|
||||
// Typora drops an alert's marker with its quote.
|
||||
let next = next_line(text, line).unwrap_or(text.len());
|
||||
edits.push((line..next, String::new()));
|
||||
line = next;
|
||||
// The marker goes; an outer opener stays.
|
||||
let next = next_line(text, line).filter(|next| *next < whole.end);
|
||||
let (cut, rest) = match next {
|
||||
Some(next) => match mark(text, next, &outer) {
|
||||
Some(mark) => (mark.end, next_line(text, next).unwrap_or(text.len())),
|
||||
None => (next, next),
|
||||
},
|
||||
None => (line_end(text, line), whole.end),
|
||||
};
|
||||
edits.push((whole.start..cut, String::new()));
|
||||
line = rest;
|
||||
}
|
||||
while line < whole.end {
|
||||
if let Some(at) = inside(text, line, &outer) {
|
||||
let end = line_end(text, line);
|
||||
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||
let mark = at + lead;
|
||||
if lead <= 3 && text[mark..end].starts_with('>') {
|
||||
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
|
||||
edits.push((mark..mark + 1 + space, String::new()));
|
||||
}
|
||||
if let Some(mark) = mark(text, line, &outer) {
|
||||
edits.push((mark, String::new()));
|
||||
}
|
||||
match next_line(text, line) {
|
||||
Some(next) => line = next,
|
||||
@@ -92,6 +97,18 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
|
||||
edits
|
||||
}
|
||||
|
||||
/// `line`'s `>` and one space after, inside `outer`.
|
||||
fn mark(text: &str, line: usize, outer: &[&Node]) -> Option<Range<usize>> {
|
||||
let at = inside(text, line, outer)?;
|
||||
let end = line_end(text, line);
|
||||
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||
let mark = at + lead;
|
||||
(lead <= 3 && text[mark..end].starts_with('>')).then(|| {
|
||||
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
|
||||
mark..mark + 1 + space
|
||||
})
|
||||
}
|
||||
|
||||
/// `> ` before each line of `blocks`, held by `path[k]`.
|
||||
fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! A paragraph or heading split at the caret, as Typora's `splitAt`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{Here, inside, line_end, line_start, next_line, placed, prev_line, task_head};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal, marks},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
/// Where the new block's text goes, ending the edit.
|
||||
pub(super) struct Room<'a> {
|
||||
pub(super) nl: &'static str,
|
||||
pub(super) lead: String,
|
||||
pub(super) blank: String,
|
||||
pub(super) containers: Vec<&'a Node>,
|
||||
}
|
||||
|
||||
impl Room<'_> {
|
||||
/// A blank line, then a new line's prefix.
|
||||
pub(super) fn gap(&self) -> String {
|
||||
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `line`'s text starts inside `containers`; a lazy line's own.
|
||||
pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
|
||||
let at = inside(text, line, containers).unwrap_or(line);
|
||||
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
|
||||
}
|
||||
|
||||
/// Back from `at` over blanks and prefixes, to `floor`.
|
||||
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let line = line_start(text, at).max(floor);
|
||||
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
|
||||
if line == floor || at > content(text, line, containers) {
|
||||
return at;
|
||||
}
|
||||
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
|
||||
}
|
||||
}
|
||||
|
||||
/// On from `at` over blanks and prefixes, to `ceil`.
|
||||
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let end = line_end(text, at).min(ceil);
|
||||
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
|
||||
if at < end {
|
||||
return at;
|
||||
}
|
||||
match next_line(text, at) {
|
||||
Some(next) if next < ceil => at = content(text, next, containers),
|
||||
_ => return ceil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline nodes with no text to cut.
|
||||
fn sealed(node: &Node) -> bool {
|
||||
matches!(
|
||||
node,
|
||||
Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// `at` off inline edges; the marks to close and to reopen.
|
||||
fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> {
|
||||
let text = here.text;
|
||||
let marked: Vec<(&Node, [Range<usize>; 2])> = (here.nodes.iter())
|
||||
.filter_map(|node| Some((*node, marks(node, text)?)))
|
||||
.collect();
|
||||
while let Some([open, _]) = (marked.iter())
|
||||
.map(|(_, holes)| holes)
|
||||
.find(|[open, _]| open.start < at && open.end == at)
|
||||
{
|
||||
at = open.start;
|
||||
}
|
||||
while let Some([_, close]) = (marked.iter())
|
||||
.map(|(_, holes)| holes)
|
||||
.find(|[_, close]| close.start == at && at < close.end)
|
||||
{
|
||||
at = close.end;
|
||||
}
|
||||
let strictly = |range: Range<usize>| range.start < at && at < range.end;
|
||||
let mut cut: Vec<&(&Node, [Range<usize>; 2])> = (marked.iter())
|
||||
.filter(|(node, _)| strictly(span(node)))
|
||||
.collect();
|
||||
let paired =
|
||||
|node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_));
|
||||
let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node)))
|
||||
|| (cut.iter()).any(|(node, [open, close])| {
|
||||
!paired(node) || strictly(open.clone()) || strictly(close.clone())
|
||||
});
|
||||
if unsafe_cut {
|
||||
return Err(EditRefusal::Across);
|
||||
}
|
||||
cut.sort_by_key(|(node, _)| span(node).start);
|
||||
let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]);
|
||||
let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]);
|
||||
Ok((at, close.collect(), open.collect()))
|
||||
}
|
||||
|
||||
/// `block` put where the selection splits `node`.
|
||||
pub(super) fn split(
|
||||
here: &Here,
|
||||
node: &Node,
|
||||
room: &Room,
|
||||
block: &str,
|
||||
inner: Option<usize>,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let (marks, body) = match node {
|
||||
Node::Heading(heading) => (
|
||||
"#".repeat(usize::from(heading.depth)) + " ",
|
||||
kids.first().map_or(whole.end, |kid| span(kid).start)
|
||||
..kids.last().map_or(whole.end, |kid| span(kid).end),
|
||||
),
|
||||
_ => (String::new(), whole.clone()),
|
||||
};
|
||||
// A caret in heading marks takes the text's edge.
|
||||
let s = here.selection.start.clamp(body.start, body.end);
|
||||
let e = here.selection.end.clamp(body.start, body.end);
|
||||
let (s, close, _) = seam(here, s)?;
|
||||
let (e, _, open) = seam(here, e)?;
|
||||
let containers = &room.containers;
|
||||
let mut from = back(text, s, body.start, containers);
|
||||
let after = forth(text, e, body.end, containers);
|
||||
let before = from > body.start;
|
||||
let follows = after < body.end;
|
||||
// GFM reads a task's first line as text.
|
||||
if !before && task_head(here.path[here.path.len() - 2], node) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
if !before {
|
||||
from = whole.start;
|
||||
}
|
||||
let to = if follows { after } else { whole.end };
|
||||
let gap = room.gap();
|
||||
let mut written = String::new();
|
||||
if before {
|
||||
written.push_str(&close);
|
||||
written.push_str(&gap);
|
||||
}
|
||||
let start = written.len();
|
||||
written.push_str(block);
|
||||
let lead = if follows { marks.len() + open.len() } else { 0 };
|
||||
let caret = match inner {
|
||||
Some(inner) => start + inner,
|
||||
None => {
|
||||
written.push_str(&gap);
|
||||
written.len() + lead
|
||||
}
|
||||
};
|
||||
if follows {
|
||||
if inner.is_some() {
|
||||
written.push_str(&gap);
|
||||
}
|
||||
written.push_str(&marks);
|
||||
written.push_str(&open);
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
let mut shift = 0;
|
||||
if !before && here.joins_before(&whole, None) {
|
||||
let line = line_start(text, whole.start);
|
||||
let blank = format!("{}{}", room.blank, room.nl);
|
||||
shift = blank.len();
|
||||
edits.push((line..line, blank));
|
||||
}
|
||||
edits.push((from..to, written));
|
||||
Ok(placed(edits, from + shift + caret))
|
||||
}
|
||||
@@ -97,7 +97,7 @@ fn block(node: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// A styled node's two marks, kept text between.
|
||||
fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
||||
pub(super) fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
||||
let whole = span(node);
|
||||
let inner = match node {
|
||||
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
|
||||
|
||||
@@ -62,10 +62,10 @@ fn kinds(text: &str) -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_set_and_toggle_as_typora() {
|
||||
fn headings_set_as_typoras_menu() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸b", Heading(2)), "## a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(2)), "a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(2)), "## a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(3)), "### a‸b");
|
||||
assert_eq!(ok("a‸", Promote), "###### a‸");
|
||||
assert_eq!(ok("# a‸", Promote), "# a‸");
|
||||
@@ -189,7 +189,12 @@ fn empty_paragraphs_come_before_and_after() {
|
||||
assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h");
|
||||
assert_eq!(ok("- a‸", ParagraphBefore), "- ‸\n\n a");
|
||||
let out = ok("- a‸", ParagraphBefore);
|
||||
assert_eq!(out, "- ‸\n a");
|
||||
assert_eq!(kinds(&out), ["list"]);
|
||||
let out = ok("x[^1]\n\n[^1]: a‸", ParagraphBefore);
|
||||
assert_eq!(out, "x[^1]\n\n[^1]: ‸\n a");
|
||||
assert_eq!(kinds(&out), ["p", "note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,3 +273,102 @@ fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) {
|
||||
);
|
||||
document.read_with(cx, |document, _| assert!(document.open.is_none()));
|
||||
}
|
||||
|
||||
/// The one list's items, each as its blocks' kinds.
|
||||
fn items(text: &str) -> Vec<Vec<&'static str>> {
|
||||
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let root = file_tree(&text);
|
||||
let Some(Node::List(list)) = root.children().and_then(|kids| kids.first()) else {
|
||||
panic!("a list first");
|
||||
};
|
||||
(list.children.iter())
|
||||
.map(|item| {
|
||||
(item
|
||||
.children()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter())
|
||||
.map(|kid| match kid {
|
||||
Node::Paragraph(_) => "p",
|
||||
Node::Code(_) => "code",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caret_in_heading_marks_splits_at_the_text() {
|
||||
use BlockCommand::Rule;
|
||||
assert_eq!(ok("‸# alpha", Rule), "---\n\n# ‸alpha");
|
||||
let out = ok("# alpha #‸##", Rule);
|
||||
assert_eq!(kinds(&out), ["h", "rule"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_list_keeps_checked_boxes() {
|
||||
assert_eq!(
|
||||
ok("- a\n- [x] b‸", BlockCommand::TaskList),
|
||||
"- [ ] a\n- [x] b‸"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_in_an_item_keeps_the_item() {
|
||||
let out = ok("- > [!NOTE]\n > a‸\n- b", BlockCommand::Quote);
|
||||
assert_eq!(out, "- a‸\n- b");
|
||||
assert_eq!(items(&out), [vec!["p"], vec!["p"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_closes_and_reopens_inline_marks() {
|
||||
use BlockCommand::Rule;
|
||||
let out = ok("**ab‸cd**", Rule);
|
||||
assert_eq!(out, "**ab**\n\n---\n\n**‸cd**");
|
||||
assert_eq!(kinds(&out), ["p", "rule", "p"]);
|
||||
assert_eq!(ran("[a‸b](u)", Rule), Err(EditRefusal::Across));
|
||||
assert_eq!(ran("`a‸b`", Rule), Err(EditRefusal::Across));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retyped_item_keeps_its_code_indent() {
|
||||
let out = ok("- code‸", BlockCommand::OrderedList);
|
||||
assert_eq!(out, "1. code‸");
|
||||
assert_eq!(items(&out), [vec!["code"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_keeps_neighbors_apart() {
|
||||
let out = ok("p\n# h‸\nq", BlockCommand::Delete);
|
||||
assert_eq!(out, "p\n‸\nq");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_marks_keep_their_text() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("alpha #‸", Heading(1));
|
||||
assert_eq!(out, "# alpha \\#‸");
|
||||
assert_eq!(kinds(&out), ["h"]);
|
||||
let out = ok("# - alpha‸", Paragraph);
|
||||
assert_eq!(out, "\\- alpha‸");
|
||||
assert_eq!(kinds(&out), ["p"]);
|
||||
assert_eq!(kinds(&ok("# 1. a‸", Paragraph)), ["p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tasks_first_text_takes_no_block() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ran("- [ ] a‸", Quote), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] ‸ab", Rule), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] ‸ab", CodeBlock), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] a‸", ParagraphBefore), Err(EditRefusal::Outside));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_past_a_list_takes_its_siblings() {
|
||||
let out = ok("- ⟨a\n\nb⟩", BlockCommand::OrderedList);
|
||||
assert_eq!(out, "1. ⟨a\n\n2. b⟩");
|
||||
assert_eq!(kinds(&out), ["list"]);
|
||||
}
|
||||
Reference in new issue
Block a user