From 892e09502507725096c445807306dca4bfc1c8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 14:36:49 -0400 Subject: [PATCH] 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) --- AGENTS.md | 2 +- src/documents/document/block.rs | 35 ++++- src/documents/document/block/check.rs | 83 ++++++++++++ src/documents/document/block/heading.rs | 62 ++++----- src/documents/document/block/insert.rs | 15 +- src/documents/document/block/list.rs | 67 ++++++--- src/documents/document/block/quote.rs | 47 +++++-- src/documents/document/block/split.rs | 62 ++++++++- src/documents/document/format.rs | 2 + src/documents/document/tests/block.rs | 6 +- src/documents/document/tests/block_edges.rs | 143 ++++++++++++++++++++ src/documents/document/tests/mod.rs | 1 + 12 files changed, 435 insertions(+), 90 deletions(-) create mode 100644 src/documents/document/block/check.rs create mode 100644 src/documents/document/tests/block_edges.rs diff --git a/AGENTS.md b/AGENTS.md index e550d37..45b63b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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 `
` breaks as `
` 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 list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. Formats and block commands share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- 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 `` 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 `
` breaks as `
` 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 list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker shares its line as `Misread`. Formats and block commands share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - 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. diff --git a/src/documents/document/block.rs b/src/documents/document/block.rs index ab4ab40..f300f2a 100644 --- a/src/documents/document/block.rs +++ b/src/documents/document/block.rs @@ -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, gone: Option<&Node>) -> bool { + pub(super) fn joins_before(&self, block: &Range, 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 { + 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, 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(_)) diff --git a/src/documents/document/block/check.rs b/src/documents/document/block/check.rs new file mode 100644 index 0000000..2922b08 --- /dev/null +++ b/src/documents/document/block/check.rs @@ -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, 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))) + }) +} diff --git a/src/documents/document/block/heading.rs b/src/documents/document/block/heading.rs index fd7e215..1e04064 100644 --- a/src/documents/document/block/heading.rs +++ b/src/documents/document/block/heading.rs @@ -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 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) -> 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 { - 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, 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 diff --git a/src/documents/document/block/insert.rs b/src/documents/document/block/insert.rs index 94be4df..f61a578 100644 --- a/src/documents/document/block/insert.rs +++ b/src/documents/document/block/insert.rs @@ -235,7 +235,7 @@ pub(super) fn paragraph(here: &Here, before: bool) -> Result Result { 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 { { 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(), diff --git a/src/documents/document/block/list.rs b/src/documents/document/block/list.rs index b7ff718..4af31f1 100644 --- a/src/documents/document/block/list.rs +++ b/src/documents/document/block/list.rs @@ -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 Result Vec<(Range, 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, String)> { +) -> Result, 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, String)> { +) -> Result, 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) } diff --git a/src/documents/document/block/quote.rs b/src/documents/document/block/quote.rs index ff6f883..6c9741f 100644 --- a/src/documents/document/block/quote.rs +++ b/src/documents/document/block/quote.rs @@ -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 { 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, String)> { +fn unwrap(here: &Here, ix: usize) -> Result, 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, 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, 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`. diff --git a/src/documents/document/block/split.rs b/src/documents/document/block/split.rs index 0c769c3..235d70d 100644 --- a/src/documents/document/block/split.rs +++ b/src/documents/document/block/split.rs @@ -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; 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")), + } } diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index 932ff2c..71a1782 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -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. diff --git a/src/documents/document/tests/block.rs b/src/documents/document/tests/block.rs index 952b24c..9bbea59 100644 --- a/src/documents/document/tests/block.rs +++ b/src/documents/document/tests/block.rs @@ -9,7 +9,7 @@ use crate::documents::{ }; /// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection. -fn ran(marked: &str, command: BlockCommand) -> Result { +pub(super) fn ran(marked: &str, command: BlockCommand) -> Result { 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 { }) } -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(); diff --git a/src/documents/document/tests/block_edges.rs b/src/documents/document/tests/block_edges.rs new file mode 100644 index 0000000..a76162f --- /dev/null +++ b/src/documents/document/tests/block_edges.rs @@ -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> { + 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 { + 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 { + 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"]); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index b5a4d8c..f234453 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -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;