documents: block checks read as drawn; alert, fence and box edges kept
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 12m1s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

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 15:03:36 -04:00
1 parent 892e095025
commit 0558f0c890
7 files changed
+229 -83

No files matched your search

+76 -50
View File
@@ -5,18 +5,24 @@ use std::ops::Range;
use markdown::mdast::Node;
use crate::documents::{
MarkdownReader, Style,
document::format::EditRefusal,
markdown::{file_tree, span, walk},
};
const KEPT: u16 = bit(Style::Code) | bit(Style::Math);
const CODE: u16 = 1 << 9;
const MATH: u16 = 1 << 10;
const HTML: u16 = 1 << 11;
/// 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 {
/// `text` with sorted `edits` written, and its parse.
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> (String, Node) {
let mut out = String::with_capacity(text.len());
let mut at = 0;
for (range, with) in edits {
@@ -25,59 +31,79 @@ pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> Node {
at = range.end;
}
out.push_str(&text[at..]);
file_tree(&out)
let root = file_tree(&out);
(out, root)
}
/// 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
const fn bit(style: Style) -> u16 {
1 << style as u16
}
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);
/// A parsed document read as the drawing reads it.
pub(super) struct Shown<'a> {
root: &'a Node,
reader: MarkdownReader<'a>,
}
impl<'a> Shown<'a> {
pub(super) fn new(source: &'a str, root: &'a Node) -> Self {
let reader = MarkdownReader::new(source, root);
Self { root, reader }
}
/// Each shown character, with its formats.
pub(super) fn all(&self) -> Vec<(char, u16)> {
let mut out = Vec::new();
self.read(self.root, &mut out);
out
}
fn read(&self, node: &Node, out: &mut Vec<(char, u16)>) {
let (value, format) = match node {
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_) => {
return out.extend(self.inline(node));
}
Node::Code(code) => (code.value.as_str(), CODE),
Node::Math(math) => (math.value.as_str(), MATH),
Node::Html(html) => (html.value.as_str(), HTML),
_ => {
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
self.read(kid, out);
}
return;
}
};
out.extend(value.chars().map(|ch| (ch, format)));
}
/// `node`'s drawn text; white space counts only in code.
pub(super) fn inline(&self, node: &Node) -> Vec<(char, u16)> {
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
let inline = self.reader.inline(kids);
let mut formats = vec![0u16; inline.text.len()];
for (range, style) in &inline.styles {
for format in &mut formats[range.clone()] {
*format |= bit(*style);
}
}
(inline.text.char_indices())
.map(|(at, ch)| (ch, formats[at]))
.filter(|(ch, format)| !ch.is_whitespace() || format & KEPT != 0)
.collect()
}
/// The level (0 a paragraph) and characters at `at`.
pub(super) fn at(&self, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
let mut nodes = Vec::new();
walk(self.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, self.inline(node)))
})
}
}
/// 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)))
})
}
+9 -3
View File
@@ -66,9 +66,15 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
};
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);
let (written, after) = check::reread(text, &change.0);
let before = check::Shown::new(text, here.root);
let now = check::Shown::new(&written, &after);
let placed = match now.at(change.1.start) {
Some((level, _)) => level == goal,
// An empty paragraph has no node.
None => goal == 0 && before.inline(node).is_empty(),
};
let kept = placed && now.all() == before.all();
match kept {
true => Ok(change),
false => Err(check::misread("a heading change would reread otherwise")),
+37 -15
View File
@@ -2,11 +2,11 @@
use std::ops::Range;
use markdown::mdast::{List, Node};
use markdown::mdast::{Code, List, Node};
use super::{
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
opener, prefix, refusal,
opener, prefix, refusal, unblocked,
};
use crate::documents::{
document::format::{Change, EditRefusal},
@@ -60,7 +60,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
&& (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),
true => unwrap(here, ix - 1, list)?,
false => {
let start = if list.ordered {
list.start.unwrap_or(1)
@@ -125,7 +125,7 @@ fn continued<'a>(
}
/// Every item's marker off, its lines out one level.
fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
fn unwrap(here: &Here, ix: usize, list: &List) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
let text = here.text;
let outer = here.containers(ix);
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
@@ -142,16 +142,23 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
edits.push((line..line, blank.clone()));
}
let first = item.children().and_then(|kids| kids.first()).map(span);
match first {
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
match &first {
Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => {
edits.push((start..kid.start, String::new()));
}
Some(_) => {
Some(kid) => {
let next = next_line(text, start).expect("an item's text lies below");
edits.push((line..next, String::new()));
if boxed {
edits.push((unbox(text, kid.start), String::new()));
}
}
None => edits.push((start..line_end(text, start), String::new())),
}
if boxed && let Some(kid) = first {
edits.extend(escaped(text, kid.start)?);
}
for (at, cut, past) in continued(text, item, &outer) {
edits.push((at..cut, " ".repeat(past)));
}
@@ -160,7 +167,26 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> 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)
}
/// The task box before the text at `kid`.
fn unbox(text: &str, kid: usize) -> Range<usize> {
let line = line_start(text, kid);
line + text[line..kid].rfind('[').expect("a task's box")..kid
}
/// A backslash where text at `at`, unboxed, opens a block.
fn escaped(text: &str, at: usize) -> Result<Option<(Range<usize>, String)>, EditRefusal> {
let mark = unblocked(&text[at..line_end(text, at)])?;
Ok(mark.map(|mark| (at + mark..at + mark, "\\".to_string())))
}
/// Whether `node`, holding `code`, is fenced in the source.
fn fenced(text: &str, node: &Node, code: &Code) -> bool {
code.lang.is_some()
|| code.value.is_empty()
|| text[span(node)].lines().count() > code.value.lines().count()
}
/// Whether the line before `line` holds text inside `outer`.
@@ -200,9 +226,8 @@ fn retype(
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()));
edits.push((unbox(text, kid), String::new()));
edits.extend(escaped(text, kid)?);
}
} else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) {
// GFM boxes only a paragraph.
@@ -211,13 +236,10 @@ fn retype(
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 => {
Some(kid @ Node::Code(code)) if !fenced(text, kid, code) => {
let line = code.value.lines().next().unwrap_or_default();
4 + line.len() - line.trim_start().len()
4 + line.len() - line.trim_start_matches(' ').len()
}
_ => 0,
};
+22 -11
View File
@@ -11,7 +11,7 @@ use super::{
use crate::documents::{
alert,
document::format::{Change, EditRefusal},
markdown::span,
markdown::{file_tree, span},
};
pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
@@ -72,27 +72,27 @@ fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRef
if let Node::Blockquote(inner) = quote
&& alert(inner).is_some()
{
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()))
{
if !marker_only(&text[whole.start..line_end(text, line)]) {
return Err(check::misread("an alert marker shares its line"));
}
// A body block keeps its indent; text sheds it.
let continues = inner
.children
.first()
.is_some_and(|head| span(head).end > line_end(text, 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) {
let lead = 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();
lead + text[lead..].len() - text[lead..].trim_start_matches([' ', '\t']).len();
if body < line_end(text, at) {
(cut, rest) = (body, next_line(text, at).unwrap_or(text.len()));
cut = if continues { body } else { lead };
rest = next_line(text, at).unwrap_or(text.len());
break;
}
next = next_line(text, at);
@@ -116,6 +116,17 @@ fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRef
Ok(edits)
}
/// Whether `line`, a quote's first, holds only an alert marker.
fn marker_only(line: &str) -> bool {
let root = file_tree(&format!("{line}\n> x"));
let Some(Node::Blockquote(quote)) = root.children().and_then(|kids| kids.first()) else {
return false;
};
matches!(alert(quote), Some((_, body)) if matches!(&body[..],
[Node::Paragraph(head)] if matches!(&head.children[..],
[Node::Text(text)] if text.value == "x")))
}
/// `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)?;
+4 -3
View File
@@ -195,13 +195,14 @@ pub(super) fn split(
}
let change = placed(edits, from + shift + caret);
let edits = &change.0;
let after_root = check::reread(text, edits);
let (written, after_root) = check::reread(text, edits);
let now = check::Shown::new(&written, &after_root);
let level = match node {
Node::Heading(heading) => heading.depth,
_ => 0,
};
let piece = |at: usize| {
check::block_at(&after_root, shifted(at, edits))
now.at(shifted(at, edits))
.and_then(|(got, chars)| (got == level).then_some(chars))
};
let head = if before {
@@ -210,7 +211,7 @@ pub(super) fn split(
Some(Vec::new())
};
let tail = if follows { piece(to) } else { Some(Vec::new()) };
let want = check::styled(node);
let want = check::Shown::new(text, here.root).inline(node);
let kept = head
.zip(tail)
.is_some_and(|(head, tail)| match here.selection.is_empty() {
@@ -141,3 +141,83 @@ fn delete_keeps_neighbors_apart_past_gone_openers() {
assert_eq!(out, "p\n\nq");
assert_eq!(kinds(&out), ["p", "p"]);
}
fn inline_codes(text: &str) -> Vec<String> {
let mut out = Vec::new();
nodes(text, &mut |node| {
if let Node::InlineCode(code) = node {
out.push(code.value.clone());
}
});
out
}
#[test]
fn a_heading_join_keeps_white_space_in_code() {
let source = "a `x\n y`‸\n===";
match ran(source, BlockCommand::Heading(3)) {
Err(EditRefusal::Misread) => {}
Ok(out) => assert_eq!(inline_codes(&plain(&out)), inline_codes(&plain(source))),
other => panic!("{other:?}"),
}
}
#[test]
fn a_split_that_draws_otherwise_refuses() {
for (source, command) in [
("<u>ab‸cd</u>", BlockCommand::Rule),
("<u>a‸\nb</u>", BlockCommand::Heading(1)),
("x:smile: ‸:smile:", BlockCommand::Rule),
] {
assert_eq!(ran(source, command), Err(EditRefusal::Misread), "{source}");
}
}
#[test]
fn an_alert_body_block_keeps_its_indent() {
let out = plain(&ok("> [!NOTE]\n>\n> co‸de", BlockCommand::Quote));
assert_eq!(
(kinds(&out), codes(&out)),
(vec!["code"], vec!["code".to_string()])
);
let out = plain(&ok(
"> [!NOTE]\n>\n> ```\n> co‸de\n> ```",
BlockCommand::Quote,
));
assert_eq!(
(kinds(&out), codes(&out)),
(vec!["code"], vec!["code".to_string()])
);
}
#[test]
fn retyping_reads_a_fence_from_its_source() {
let out = plain(&ok("- ```lang‸", BlockCommand::OrderedList));
assert_eq!(codes(&out), vec!["```lang".to_string()]);
let out = plain(&ok("- \u{a0}code‸", BlockCommand::OrderedList));
assert_eq!(codes(&out), vec!["\u{a0}code".to_string()]);
}
#[test]
fn a_removed_box_leaves_text() {
assert_eq!(
plain(&ok("- [x] ---‸", BlockCommand::BulletList)),
"- \\---"
);
assert_eq!(
plain(&ok("- [x] # h‸", BlockCommand::BulletList)),
"- \\# h"
);
assert_eq!(plain(&ok("-\n [x] a‸", BlockCommand::TaskList)), "a");
assert_eq!(plain(&ok("- [x] # h‸", BlockCommand::TaskList)), "\\# h");
}
#[test]
fn an_empty_heading_becomes_an_empty_paragraph() {
assert!(ran("# ‸", BlockCommand::Paragraph).is_ok());
}
#[test]
fn an_escaped_alert_marker_on_its_own_line_unwraps() {
assert_eq!(plain(&ok("> \\[!NOTE]\n> a‸", BlockCommand::Quote)), "a");
}