documents: block checks read as drawn; alert, fence and box edges kept
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
892e095025
commit
0558f0c890
7 files changed
+229
-83
No files matched your search
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user