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:
1 parent
b37031b00b
commit
892e095025
12 files changed
+435
-90
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] 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"]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user