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

+1 -1
View File
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
+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");
}