documents: block commands reread their result

Fixes R4.7 round 2: a heading change or split is parsed again and
refused as Misread when its text, formats or kind would change;
closing hashes and block openers are escaped at split ends; task
boxes below a marker stay; indented code is written from its value;
alerts unwrap past blank and lazy lines; delete counts every gone
opener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 14:36:49 -04:00
1 parent b37031b00b
commit 892e095025
12 files changed
+435 -90

No files matched your search

+31 -4
View File
@@ -1,5 +1,6 @@
//! Block commands at the caret, as Typora's Paragraph menu (R4.7).
mod check;
mod heading;
mod insert;
mod list;
@@ -139,7 +140,7 @@ impl<'a> Here<'a> {
}
/// Whether a paragraph ending on the line before joins `block`.
pub(super) fn joins_before(&self, block: &Range<usize>, gone: Option<&Node>) -> bool {
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;
@@ -164,13 +165,13 @@ impl<'a> Here<'a> {
_ => false,
}
});
starts && !self.opens(next, None)
starts && !self.opens(next, &[])
}
/// Whether `line` opens a container that stays and interrupts.
fn opens(&self, line: usize, gone: Option<&Node>) -> bool {
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.is_none_or(|gone| !std::ptr::eq(gone, node));
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
@@ -197,6 +198,32 @@ pub(super) fn task_head(holder: &Node, node: &Node) -> bool {
&& 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(_))
+83
View File
@@ -0,0 +1,83 @@
//! A command's result read back: its text and formats kept.
use std::ops::Range;
use markdown::mdast::Node;
use crate::documents::{
document::format::EditRefusal,
markdown::{file_tree, span, walk},
};
/// Refuses a result that reads as something else.
pub(super) fn misread(why: &str) -> EditRefusal {
log::warn!("block command: {why}");
EditRefusal::Misread
}
/// `text` with sorted `edits` written, parsed.
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> Node {
let mut out = String::with_capacity(text.len());
let mut at = 0;
for (range, with) in edits {
out.push_str(&text[at..range.start]);
out.push_str(with);
at = range.end;
}
out.push_str(&text[at..]);
file_tree(&out)
}
/// Each shown character under `node`, with its formats.
pub(super) fn styled(node: &Node) -> Vec<(char, u16)> {
let mut out = Vec::new();
read(node, 0, &mut out);
out
}
fn read(node: &Node, formats: u16, out: &mut Vec<(char, u16)>) {
let (own, value) = match node {
Node::Text(text) => (0, Some(text.value.as_str())),
Node::Strong(_) => (1, None),
Node::Emphasis(_) => (2, None),
Node::Delete(_) => (4, None),
Node::Link(_) | Node::LinkReference(_) => (8, None),
Node::InlineCode(code) => (16, Some(code.value.as_str())),
Node::InlineMath(math) => (32, Some(math.value.as_str())),
Node::Html(html) => (64, Some(html.value.as_str())),
Node::Image(image) => (128, Some(image.alt.as_str())),
Node::ImageReference(image) => (128, Some(image.alt.as_str())),
Node::FootnoteReference(note) => (256, Some(note.identifier.as_str())),
Node::Code(code) => (512, Some(code.value.as_str())),
Node::Math(math) => (1024, Some(math.value.as_str())),
_ => (0, None),
};
let formats = formats | own;
match value {
Some(value) => out.extend(
(value.chars())
.filter(|ch| !ch.is_whitespace())
.map(|ch| (ch, formats)),
),
None => {
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
read(kid, formats, out);
}
}
}
}
/// The level (0 a paragraph) and characters at `at`.
pub(super) fn block_at(root: &Node, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
let mut nodes = Vec::new();
walk(root, &mut nodes);
nodes.into_iter().find_map(|node| {
let level = match node {
Node::Paragraph(_) => 0,
Node::Heading(heading) => heading.depth,
_ => return None,
};
let whole = span(node);
(whole.start <= at && at <= whole.end).then(|| (level, styled(node)))
})
}
+23 -39
View File
@@ -5,12 +5,12 @@ use std::ops::Range;
use markdown::mdast::Node;
use super::{
BlockCommand, Here, change, eol, insert, inside, line_end, line_start, next_line, prefix,
refusal,
BlockCommand, Here, change, check, closing, eol, insert, inside, line_end, line_start,
next_line, prefix, prev_line, refusal, unblocked,
};
use crate::documents::{
document::format::{Change, EditRefusal},
markdown::{file_tree, span},
markdown::span,
};
/// The level `command` asks at `level`; 0 is a paragraph.
@@ -64,7 +64,15 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
(_, 0) => to_paragraph(here, node, &containers)?,
_ => relevel(here, node, &containers, goal),
};
Ok(change(edits, caret))
let change = change(edits, caret);
// Every shown character stays; the caret's block is `goal`.
let after = check::reread(text, &change.0);
let kept = check::styled(&after) == check::styled(here.root)
&& check::block_at(&after, change.1.start).is_some_and(|(level, _)| level == goal);
match kept {
true => Ok(change),
false => Err(check::misread("a heading change would reread otherwise")),
}
}
/// Whether `node` is an ATX heading, `#` led.
@@ -79,36 +87,6 @@ fn task_head(here: &Here, whole: &Range<usize>) -> bool {
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.
fn to_heading(
here: &Here,
@@ -135,8 +113,8 @@ 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()));
if let Some(run) = closing(&text[at..line_end(text, at)]) {
edits.push((at + run..at + run, "\\".to_string()));
}
} else if !lazy.is_empty() {
edits.push((at..at, lazy.to_string()));
@@ -159,7 +137,7 @@ fn to_paragraph(
let whole = span(node);
let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text));
let mut edits = Vec::new();
if here.joins_before(&whole, None) {
if here.joins_before(&whole, &[]) {
let line = line_start(text, whole.start);
edits.push((line..line, blank.clone()));
}
@@ -169,8 +147,10 @@ fn to_paragraph(
(Some(first), Some(last)) => {
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()));
// The body kept: closing hashes go.
let end = line_end(text, start).min(span(last).end);
if let Some(mark) = unblocked(&text[start..end])? {
edits.push((start + mark..start + mark, "\\".to_string()));
}
edits.push((span(last).end..whole.end, String::new()));
}
@@ -225,6 +205,10 @@ fn relevel(
edits.push((end..from, " ".to_string()));
line = next;
}
let last = prev_line(text, under).expect("a setext heading has text");
if let Some(run) = closing(&text[last..line_end(text, last)]) {
edits.push((last + run..last + run, "\\".to_string()));
}
let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len();
edits.push((under - ending.min(2)..whole.end, String::new()));
edits
+12 -3
View File
@@ -235,7 +235,7 @@ pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal
if before {
let mut edits = Vec::new();
let mut shift = 0;
if here.joins_before(&whole, None) {
if here.joins_before(&whole, &[]) {
let line = line_start(text, whole.start);
let blank = format!("{}{}", room.blank, room.nl);
shift = blank.len();
@@ -287,7 +287,6 @@ pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
ix -= 1;
}
let whole = span(here.path[ix]);
let outer = here.containers(ix);
let line = line_start(text, whole.start);
if !text[line..whole.start]
.chars()
@@ -295,13 +294,23 @@ pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
{
return Ok(placed(vec![(whole.clone(), String::new())], whole.start));
}
// Containers wholly on the lines go too.
let last = line_end(text, whole.end);
let top = (1..=ix)
.find(|j| {
let held = span(here.path[*j]);
line <= held.start && held.end <= last
})
.expect("the block lies on its lines");
let gone = &here.path[top..=ix];
let outer = here.containers(top);
let mut end = next_line(text, whole.end).unwrap_or(text.len());
let blank_above = prev_line(text, line).is_none_or(|prev| !text_on(text, prev, &outer));
if blank_above && end < text.len() && !text_on(text, end, &outer) {
end = next_line(text, end).unwrap_or(text.len());
}
// Neighbors that would join keep a blank line between.
let joins = here.joins_before(&whole, None) && here.joins_after(&whole);
let joins = here.joins_before(&whole, gone) && here.joins_after(&whole);
let with = match joins {
true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)),
false => String::new(),
+46 -21
View File
@@ -6,7 +6,7 @@ use markdown::mdast::{List, Node};
use super::{
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
opener, prefix,
opener, prefix, refusal,
};
use crate::documents::{
document::format::{Change, EditRefusal},
@@ -68,7 +68,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
1
};
let outer = here.containers(ix - 1);
retype(here.text, path[ix - 1], list, &outer, command, start)
retype(here.text, path[ix - 1], list, &outer, command, start)?
}
};
return Ok(change(edits, caret));
@@ -83,7 +83,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
return Ok(change(vec![(at..at, marker(command, 1, '-'))], caret));
}
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
Ok(change(wrap(here, k, &blocks, command), caret))
Ok(change(wrap(here, k, &blocks, command)?, caret))
}
/// Indent from `at` to column `want`: its end, columns past.
@@ -135,7 +135,7 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
let start = span(item).start;
let line = line_start(text, start);
let joins = match n {
0 => here.joins_before(&whole, Some(here.path[ix])),
0 => here.joins_before(&whole, &[here.path[ix]]),
_ => prev_text(text, line, &outer),
};
if joins {
@@ -170,7 +170,7 @@ fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
})
}
/// Every item's marker as `kind`'s from `start`, lines moved to match.
/// Each marker as `kind`'s from `start`; lines follow.
fn retype(
text: &str,
node: &Node,
@@ -178,36 +178,58 @@ fn retype(
outer: &[&Node],
kind: BlockCommand,
start: u32,
) -> Vec<(Range<usize>, String)> {
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
let bullet = match list.ordered {
true => '-',
false => text[span(node).start..].chars().next().unwrap_or('-'),
};
let mut edits = Vec::new();
let task = kind == BlockCommand::TaskList;
for (n, item) in list.children.iter().enumerate() {
let number = start + n as u32;
let at = span(item).start;
let (mark, spaces) = super::marker(text, item);
let first = item.children().and_then(|kids| kids.first()).map(span);
let first = item.children().and_then(|kids| kids.first());
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
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} "))
let body = at + mark + spaces;
let blank = body == line_end(text, at);
if boxed {
// A box stays when the kind keeps boxes.
let kind = if task { BlockCommand::BulletList } else { kind };
let end = if blank { opener(text, item) } else { body };
edits.push((at..end, marker(kind, number, bullet)));
if !task {
let kid = span(first.expect("a boxed item has text")).start;
let line = line_start(text, kid);
let mark = line + text[line..kid].rfind('[').expect("a task's box");
edits.push((mark..kid, String::new()));
}
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, written));
} else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) {
// GFM boxes only a paragraph.
return Err(refusal(kid));
} else if blank {
edits.push((at..opener(text, item), marker(kind, number, bullet)));
} else {
// Indented code is written again from its value.
let fence = ["```", "~~~"]
.iter()
.any(|fence| text[body..].starts_with(fence));
let pad = match first {
Some(Node::Code(code)) if !fence => {
let line = code.value.lines().next().unwrap_or_default();
4 + line.len() - line.trim_start().len()
}
_ => 0,
};
edits.push((at..body, marker(kind, number, bullet) + &" ".repeat(pad)));
}
let want = column(text, at) + width(kind, number);
for (from, cut, past) in continued(text, item, outer) {
let pad = want.saturating_sub(column(text, from)) + past;
edits.push((from..cut, " ".repeat(pad)));
}
}
edits
Ok(edits)
}
/// Each paragraph line, and each other block, made an item.
@@ -216,7 +238,7 @@ fn wrap(
k: usize,
blocks: &[&Node],
command: BlockCommand,
) -> Vec<(Range<usize>, String)> {
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
let text = here.text;
let outer = here.containers(k + 1);
let lead = prefix(text, &outer);
@@ -225,11 +247,14 @@ fn wrap(
for block in blocks {
if let Node::List(list) = block {
if kind(list) != command {
edits.extend(retype(text, block, list, &outer, command, n));
edits.extend(retype(text, block, list, &outer, command, n)?);
}
n += list.children.len() as u32;
continue;
}
if command == BlockCommand::TaskList && !matches!(block, Node::Paragraph(_)) {
return Err(refusal(block));
}
let whole = span(block);
let mut line = line_start(text, whole.start);
while line <= whole.end {
@@ -264,5 +289,5 @@ fn wrap(
}
}
}
edits
Ok(edits)
}
+33 -14
View File
@@ -4,7 +4,10 @@ use std::ops::Range;
use markdown::mdast::Node;
use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix, task_head};
use super::{
Here, change, check, eol, inside, line_end, line_start, next_line, prefix, split::content,
task_head,
};
use crate::documents::{
alert,
document::format::{Change, EditRefusal},
@@ -20,7 +23,7 @@ pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
if let Node::Blockquote(_) = here.path[ix]
&& whole(here, ix)
{
return Ok(change(unwrap(here, ix), caret));
return unwrap(here, ix).map(|edits| change(edits, caret));
}
if here.leaf().is_none() && here.selection.is_empty() {
if !here.blank() {
@@ -55,7 +58,7 @@ fn whole(here: &Here, ix: usize) -> bool {
}
/// One `>` off each line of the quote at `ix`.
fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
let text = here.text;
let quote = here.path[ix];
let whole = span(quote);
@@ -63,21 +66,37 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
let mut edits = Vec::new();
let mut line = line_start(text, whole.start);
if here.joins_before(&whole, Some(quote)) {
if here.joins_before(&whole, &[quote]) {
edits.push((line..line, blank.clone()));
}
if let Node::Blockquote(inner) = quote
&& alert(inner).is_some()
{
// 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),
};
let head = text[whole.start + 1..line_end(text, line)].trim();
let name = head
.strip_prefix("[!")
.and_then(|head| head.strip_suffix(']'));
if !name
.is_some_and(|name| !name.is_empty() && name.chars().all(|ch| ch.is_ascii_alphabetic()))
{
return Err(check::misread("an alert marker shares its line"));
}
// The marker goes with blank lines; an outer opener stays.
let (mut cut, mut rest) = (whole.end, whole.end);
let mut next = next_line(text, line);
while let Some(at) = next.filter(|at| *at < whole.end) {
let body = match mark(text, at, &outer) {
Some(mark) => mark.end,
None => content(text, at, &outer),
};
let body =
body + text[body..].len() - text[body..].trim_start_matches([' ', '\t']).len();
if body < line_end(text, at) {
(cut, rest) = (body, next_line(text, at).unwrap_or(text.len()));
break;
}
next = next_line(text, at);
}
edits.push((whole.start..cut, String::new()));
line = rest;
}
@@ -94,7 +113,7 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
let next = next_line(text, whole.end).expect("a joining line follows");
edits.push((next..next, blank.clone()));
}
edits
Ok(edits)
}
/// `line`'s `>` and one space after, inside `outer`.
+57 -5
View File
@@ -1,10 +1,13 @@
//! A paragraph or heading split at the caret, as Typora's `splitAt`.
//! A paragraph or heading split, 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 super::{
Here, check, closing, inside, line_end, line_start, next_line, placed, prev_line, shifted,
task_head, unblocked,
};
use crate::documents::{
document::format::{Change, EditRefusal, marks},
markdown::span,
@@ -66,7 +69,7 @@ fn sealed(node: &Node) -> bool {
)
}
/// `at` off inline edges; the marks to close and to reopen.
/// `at` off inline edges; marks to close, then 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())
@@ -165,12 +168,61 @@ pub(super) fn split(
}
let mut edits = Vec::new();
let mut shift = 0;
if !before && here.joins_before(&whole, None) {
if !before && here.joins_before(&whole, &[]) {
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))
// A heading's cut end must not close it.
let line = line_start(text, from);
if before
&& close.is_empty()
&& matches!(node, Node::Heading(_))
&& let Some(run) = closing(&text[line..from])
{
edits.push((line + run..line + run, "\\".to_string()));
shift += 1;
}
// The rest must not open a block.
if follows
&& marks.is_empty()
&& open.is_empty()
&& let Some(mark) = unblocked(&text[after..line_end(text, after)])?
{
edits.push((after + mark..after + mark, "\\".to_string()));
}
let change = placed(edits, from + shift + caret);
let edits = &change.0;
let after_root = check::reread(text, edits);
let level = match node {
Node::Heading(heading) => heading.depth,
_ => 0,
};
let piece = |at: usize| {
check::block_at(&after_root, shifted(at, edits))
.and_then(|(got, chars)| (got == level).then_some(chars))
};
let head = if before {
piece(from - 1)
} else {
Some(Vec::new())
};
let tail = if follows { piece(to) } else { Some(Vec::new()) };
let want = check::styled(node);
let kept = head
.zip(tail)
.is_some_and(|(head, tail)| match here.selection.is_empty() {
true => [head, tail].concat() == want,
false => {
want.starts_with(&head)
&& want.ends_with(&tail)
&& head.len() + tail.len() <= want.len()
}
});
match kept {
true => Ok(change),
false => Err(check::misread("a split would reread otherwise")),
}
}
+2
View File
@@ -48,6 +48,8 @@ pub enum EditRefusal {
Busy,
/// The text changed before the edit was read.
Changed,
/// The result would read as other text or blocks.
Misread,
}
/// The edits and the selection after them.
+3 -3
View File
@@ -9,7 +9,7 @@ use crate::documents::{
};
/// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection.
fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
pub(super) fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
let caret = marked.find('‸');
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
let selection = match caret {
@@ -35,12 +35,12 @@ fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
})
}
fn ok(marked: &str, command: BlockCommand) -> String {
pub(super) fn ok(marked: &str, command: BlockCommand) -> String {
ran(marked, command).expect("ran")
}
/// The top-level blocks `text` reads as.
fn kinds(text: &str) -> Vec<&'static str> {
pub(super) fn kinds(text: &str) -> Vec<&'static str> {
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
let root = file_tree(&text);
let kids = root.children().map(Vec::as_slice).unwrap_or_default();
+143
View File
@@ -0,0 +1,143 @@
//! Block commands whose results reread as written.
use markdown::mdast::Node;
use super::block::{kinds, ok, ran};
use crate::documents::{BlockCommand, EditRefusal, markdown::file_tree};
fn plain(marked: &str) -> String {
marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect()
}
/// Every node of `text`, in document order.
fn nodes(text: &str, keep: &mut dyn FnMut(&Node)) {
fn walk(node: &Node, keep: &mut dyn FnMut(&Node)) {
keep(node);
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
walk(kid, keep);
}
}
walk(&file_tree(text), keep);
}
fn checks(text: &str) -> Vec<Option<bool>> {
let mut out = Vec::new();
nodes(text, &mut |node| {
if let Node::ListItem(item) = node {
out.push(item.checked);
}
});
out
}
fn codes(text: &str) -> Vec<String> {
let mut out = Vec::new();
nodes(text, &mut |node| {
if let Node::Code(code) = node {
out.push(code.value.clone());
}
});
out
}
fn texts(text: &str) -> Vec<String> {
let mut out = Vec::new();
nodes(text, &mut |node| {
if let Node::Text(text) = node {
out.push(text.value.clone());
}
});
out
}
#[test]
fn a_split_that_would_reread_otherwise_refuses() {
assert_eq!(
ran("~~ab‸~cd~~", BlockCommand::Rule),
Err(EditRefusal::Misread)
);
assert_eq!(
ran("__ab‸_cd__", BlockCommand::Rule),
Err(EditRefusal::Misread)
);
}
#[test]
fn a_split_rest_that_would_open_a_block_is_escaped() {
let out = plain(&ok("a ‸- b", BlockCommand::Rule));
assert_eq!(out, "a\n\n---\n\n\\- b");
assert_eq!(kinds(&out), ["p", "rule", "p"]);
}
#[test]
fn closing_hashes_stay_text_wherever_a_heading_line_ends() {
let out = plain(&ok("alpha #‸\n===", BlockCommand::Heading(3)));
assert_eq!(out, "### alpha \\#");
assert_eq!(texts(&out), ["alpha #"]);
let out = plain(&ok("# a #‸ b", BlockCommand::Rule));
assert_eq!(kinds(&out), ["h", "rule", "h"]);
assert_eq!(texts(&out), ["a #", "b"]);
}
#[test]
fn a_paragraph_reads_the_heading_body_without_closing_hashes() {
let out = plain(&ok("# ---‸ ###", BlockCommand::Paragraph));
assert_eq!(out, "\\---");
assert_eq!(kinds(&out), ["p"]);
let out = plain(&ok("# [x]: /u‸ ###", BlockCommand::Paragraph));
assert_eq!(kinds(&out), ["p"]);
assert_eq!(texts(&out).concat(), "[x]: /u");
}
#[test]
fn a_task_box_below_its_marker_stays_checked() {
let out = plain(&ok("1.‸\n [x] a", BlockCommand::TaskList));
assert_eq!(checks(&out), [Some(true)]);
assert_eq!(texts(&out), ["a"]);
}
#[test]
fn retyping_keeps_code_columns_past_tabs() {
let source = "-\t\t‸code";
let out = plain(&ok(source, BlockCommand::OrderedList));
assert_eq!(codes(&out), codes(&plain(source)));
assert_eq!(codes(&out).len(), 1);
}
#[test]
fn a_task_cannot_box_code() {
assert_eq!(
ran("- ‸a", BlockCommand::TaskList),
Err(EditRefusal::Code)
);
}
#[test]
fn an_alert_unwraps_into_its_holder() {
assert_eq!(
plain(&ok("- > [!NOTE]‸\n a", BlockCommand::Quote)),
"- a"
);
assert_eq!(
plain(&ok("> > [!NOTE]‸\n> a", BlockCommand::Quote)),
"> a"
);
let out = plain(&ok("- > [!NOTE]‸\n >\n >\n > a", BlockCommand::Quote));
assert_eq!(out, "- a");
assert_eq!(kinds(&out), ["list"]);
}
#[test]
fn an_alert_marker_sharing_its_line_refuses() {
assert_eq!(
ran("> [!NOTE]&#10;a‸", BlockCommand::Quote),
Err(EditRefusal::Misread)
);
}
#[test]
fn delete_keeps_neighbors_apart_past_gone_openers() {
let out = plain(&ok("p\n> # ‸h\nq", BlockCommand::Delete));
assert_eq!(out, "p\n\nq");
assert_eq!(kinds(&out), ["p", "p"]);
}
+1
View File
@@ -367,6 +367,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
mod attach;
mod block;
mod block_edges;
mod format;
mod history;
mod links;