documents: lists go on inside quotes and after tabs; a waiting match lapses with its text and yields to a newer one
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
edc328aadf
commit
e162082f6f
4 files changed
+162
-91
No files matched your search
@@ -15,6 +15,8 @@ impl MarkdownDocument {
|
||||
.matches('\n')
|
||||
.count()
|
||||
};
|
||||
// A newer request takes the place of one waiting.
|
||||
self.showing = None;
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => {
|
||||
self.editor
|
||||
@@ -26,8 +28,9 @@ impl MarkdownDocument {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
// A long text: shown once read.
|
||||
self.showing = Some(range);
|
||||
// Shown once read, if nothing else changed it.
|
||||
let expected = self.edits + self.writes as u64;
|
||||
self.showing = Some((range, expected));
|
||||
return cx.notify();
|
||||
}
|
||||
let ix = self.block_at(range.start);
|
||||
@@ -55,13 +58,9 @@ impl MarkdownDocument {
|
||||
if self.showing.is_none() || !self.read_now(cx) {
|
||||
return;
|
||||
}
|
||||
let range = self.showing.take().expect("checked above");
|
||||
let text = self.editor.read(cx).text();
|
||||
let fits = range.end <= text.len()
|
||||
&& text.is_char_boundary(range.start)
|
||||
&& text.is_char_boundary(range.end);
|
||||
if !fits {
|
||||
return log::warn!("markdown document: a waiting match outlived its text");
|
||||
let (range, expected) = self.showing.take().expect("checked above");
|
||||
if self.edits != expected {
|
||||
return log::info!("markdown document: a waiting match lapsed with its text");
|
||||
}
|
||||
self.show_match(range, window, cx);
|
||||
}
|
||||
|
||||
@@ -11,38 +11,8 @@ use crate::documents::markdown::{span, tree, walk};
|
||||
/// Spaces one indent step adds.
|
||||
const STEP: usize = 4;
|
||||
|
||||
/// What Enter makes of a list line.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Next {
|
||||
/// A new item: this follows the line break.
|
||||
Item(String),
|
||||
/// An empty item ends the list: the line empties.
|
||||
End,
|
||||
}
|
||||
|
||||
/// The item after `line`; none outside a list.
|
||||
pub(super) fn next(line: &str) -> Option<Next> {
|
||||
let indent = line.len() - line.trim_start_matches(' ').len();
|
||||
let rest = &line[indent..];
|
||||
let ruled = rest.chars().filter(|ch| "-*_".contains(*ch)).count() >= 3
|
||||
&& rest.chars().all(|ch| "-*_ ".contains(ch));
|
||||
if ruled {
|
||||
return None;
|
||||
}
|
||||
let (mark, after) = bullet(rest).or_else(|| ordered(rest))?;
|
||||
let body = &rest[after..];
|
||||
let (task, body) = match body.get(..4) {
|
||||
Some("[ ] " | "[x] " | "[X] ") => ("[ ] ", &body[4..]),
|
||||
_ => ("", body),
|
||||
};
|
||||
if body.trim().is_empty() {
|
||||
return Some(Next::End);
|
||||
}
|
||||
Some(Next::Item(format!("{}{mark}{task}", " ".repeat(indent))))
|
||||
}
|
||||
|
||||
/// Whether `at` sits in a list item, not code.
|
||||
fn listed(source: &str, at: usize) -> bool {
|
||||
/// Enter at `at`: the edit, none outside list items.
|
||||
pub(super) fn enter(source: &str, at: usize) -> Option<(Range<usize>, String)> {
|
||||
let root = tree(source);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
@@ -50,21 +20,60 @@ fn listed(source: &str, at: usize) -> bool {
|
||||
.into_iter()
|
||||
.filter(|node| span(node).start <= at && at <= span(node).end)
|
||||
.collect();
|
||||
let raw = |node: &&&mdast::Node| matches!(node, mdast::Node::Code(_) | mdast::Node::Math(_));
|
||||
if held.iter().any(|node| raw(&node)) {
|
||||
return None;
|
||||
}
|
||||
let item = held
|
||||
.iter()
|
||||
.any(|node| matches!(node, mdast::Node::ListItem(_)));
|
||||
item && !held
|
||||
.iter()
|
||||
.any(|node| matches!(node, mdast::Node::Code(_) | mdast::Node::Math(_)))
|
||||
.filter(|node| matches!(node, mdast::Node::ListItem(_)))
|
||||
.min_by_key(|node| span(node).len())?;
|
||||
// The item may start at its indent; the marker follows.
|
||||
let lead = &source[span(item).start..];
|
||||
let start = span(item).start + lead.len() - lead.trim_start_matches([' ', '\t']).len();
|
||||
let first = source[..start].rfind('\n').map_or(0, |found| found + 1);
|
||||
// Quote marks stay; a parent's marker becomes spaces.
|
||||
let container: String = source[first..start]
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == '>' || ch.is_whitespace() {
|
||||
ch
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rest = &source[start..];
|
||||
let (marker, after) = bullet(rest).or_else(|| ordered(rest))?;
|
||||
let gap = rest[after..].len() - rest[after..].trim_start_matches([' ', '\t']).len();
|
||||
let gap = match &rest[after..after + gap] {
|
||||
"" => " ",
|
||||
written => written,
|
||||
};
|
||||
let task = matches!(item, mdast::Node::ListItem(item) if item.checked.is_some());
|
||||
let body = source[start + after..span(item).end].trim_start();
|
||||
let body = match task {
|
||||
true => body.get(3..).unwrap_or_default(),
|
||||
false => body,
|
||||
};
|
||||
let end = source[at..]
|
||||
.find('\n')
|
||||
.map_or(source.len(), |found| at + found);
|
||||
if body.trim().is_empty() {
|
||||
// An empty item ends the list.
|
||||
return (at == end).then(|| (start..end, String::new()));
|
||||
}
|
||||
let task = if task { "[ ] " } else { "" };
|
||||
Some((at..at, format!("\n{container}{marker}{gap}{task}")))
|
||||
}
|
||||
|
||||
/// A bullet marker, and where its text starts.
|
||||
/// A bullet marker and its length.
|
||||
fn bullet(rest: &str) -> Option<(String, usize)> {
|
||||
let mark = rest.chars().next().filter(|ch| "-*+".contains(*ch))?;
|
||||
rest[1..].starts_with(' ').then(|| (format!("{mark} "), 2))
|
||||
Some((mark.to_string(), 1))
|
||||
}
|
||||
|
||||
/// A number marker, counted on; where text starts.
|
||||
/// A number marker, counted on, and its length.
|
||||
fn ordered(rest: &str) -> Option<(String, usize)> {
|
||||
let digits = rest.chars().take_while(char::is_ascii_digit).count();
|
||||
if !(1..=9).contains(&digits) {
|
||||
@@ -74,11 +83,8 @@ fn ordered(rest: &str) -> Option<(String, usize)> {
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|ch| ".)".contains(*ch))?;
|
||||
if !rest[digits + 1..].starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
let number: u64 = rest[..digits].parse().expect("ascii digits");
|
||||
Some((format!("{}{close} ", number + 1), digits + 2))
|
||||
Some((format!("{}{close}", number + 1), digits + 1))
|
||||
}
|
||||
|
||||
/// Lines `range` touches, a step in or out.
|
||||
@@ -152,22 +158,13 @@ impl MarkdownDocument {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !listed(&text[block.clone()], caret.start - block.start) {
|
||||
let at = caret.start - block.start;
|
||||
let Some((range, typed)) = enter(&text[block.clone()], at) else {
|
||||
return false;
|
||||
}
|
||||
let start = text[..caret.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let end = text[caret.start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| caret.start + at);
|
||||
let (edit, after) = match next(&text[start..end]) {
|
||||
Some(Next::Item(prefix)) => {
|
||||
let typed = format!("\n{prefix}");
|
||||
let after = caret.start + typed.len();
|
||||
((caret.clone(), typed), after)
|
||||
}
|
||||
Some(Next::End) if caret.start == end => ((start..end, String::new()), start),
|
||||
_ => return false,
|
||||
};
|
||||
let range = range.start + block.start..range.end + block.start;
|
||||
let after = range.start + typed.len();
|
||||
let edit = (range, typed);
|
||||
log::info!("markdown document: a list goes on in source");
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([edit], cx);
|
||||
@@ -186,17 +183,11 @@ impl MarkdownDocument {
|
||||
let input = input.read(cx);
|
||||
(input.text().to_string(), input.selection())
|
||||
};
|
||||
if !caret.is_empty() || !listed(&text, caret.start) {
|
||||
if !caret.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let start = text[..caret.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let end = text[caret.start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| caret.start + at);
|
||||
let (range, typed) = match next(&text[start..end]) {
|
||||
Some(Next::Item(prefix)) => (caret, format!("\n{prefix}")),
|
||||
Some(Next::End) if caret.start == end => (start..end, String::new()),
|
||||
_ => return false,
|
||||
let Some((range, typed)) = enter(&text, caret.start) else {
|
||||
return false;
|
||||
};
|
||||
log::info!("markdown document: a list goes on in a block");
|
||||
input.update(cx, |input, cx| {
|
||||
@@ -226,26 +217,53 @@ impl MarkdownDocument {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Next, next, shift};
|
||||
use super::{enter, shift};
|
||||
|
||||
/// Enter at the end of `source`'s line holding `at`.
|
||||
fn entered(source: &str, at: &str) -> Option<String> {
|
||||
let at = source.find(at).expect("in the text") + at.len();
|
||||
let (range, typed) = enter(source, at)?;
|
||||
Some(format!(
|
||||
"{}{typed}{}",
|
||||
&source[..range.start],
|
||||
&source[range.end..]
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_go_on_with_their_marker() {
|
||||
let item = |line| match next(line) {
|
||||
Some(Next::Item(prefix)) => prefix,
|
||||
other => panic!("{line:?} made {other:?}"),
|
||||
};
|
||||
assert_eq!(item("- a"), "- ");
|
||||
assert_eq!(item(" * a"), " * ");
|
||||
assert_eq!(item("9. a"), "10. ");
|
||||
assert_eq!(item("3) a"), "4) ");
|
||||
assert_eq!(item("- [x] done"), "- [ ] ");
|
||||
assert_eq!(next("- "), Some(Next::End));
|
||||
assert_eq!(next("1. [ ] "), Some(Next::End));
|
||||
for plain in ["text", "-a", "* * *", "---", "1.a", ""] {
|
||||
assert_eq!(next(plain), None, "{plain:?}");
|
||||
assert_eq!(entered("- a", "a").as_deref(), Some("- a\n- "));
|
||||
assert_eq!(entered(" * a", "a").as_deref(), Some(" * a\n * "));
|
||||
assert_eq!(entered("9. a", "a").as_deref(), Some("9. a\n10. "));
|
||||
assert_eq!(entered("3) a", "a").as_deref(), Some("3) a\n4) "));
|
||||
assert_eq!(
|
||||
entered("- [x] done", "done").as_deref(),
|
||||
Some("- [x] done\n- [ ] ")
|
||||
);
|
||||
assert_eq!(entered("- a\n- ", "- a\n- ").as_deref(), Some("- a\n"));
|
||||
let plain = [
|
||||
("text", "text"),
|
||||
("-a", "-a"),
|
||||
("* * *", "* * *"),
|
||||
("```\n- a\n```", "- a"),
|
||||
("1.a", "1.a"),
|
||||
];
|
||||
for (plain, at) in plain {
|
||||
assert_eq!(entered(plain, at), None, "{plain:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_tabs_and_nesting_carry_on() {
|
||||
assert_eq!(entered("> - a", "a").as_deref(), Some("> - a\n> - "));
|
||||
assert_eq!(entered("-\ta", "a").as_deref(), Some("-\ta\n-\t"));
|
||||
assert_eq!(
|
||||
entered("- a\n\t- b", "b").as_deref(),
|
||||
Some("- a\n\t- b\n\t- ")
|
||||
);
|
||||
assert_eq!(entered("- - a", "a").as_deref(), Some("- - a\n - "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_shift_in_and_out_with_the_selection() {
|
||||
assert_eq!(shift("a\nb", 0..0, true), (" a\nb".into(), 4..4));
|
||||
|
||||
@@ -79,8 +79,8 @@ pub struct MarkdownDocument {
|
||||
asks: u64,
|
||||
/// Its own write-backs whose changes are still to come.
|
||||
writes: usize,
|
||||
/// A match asked for before its text was read.
|
||||
showing: Option<Range<usize>>,
|
||||
/// A match asked before its text was read; its version.
|
||||
showing: Option<(Range<usize>, u64)>,
|
||||
focus: FocusHandle,
|
||||
on_link: Option<OnLink>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
|
||||
@@ -147,3 +147,57 @@ fn preview_takes_no_edits(cx: &mut TestAppContext) {
|
||||
assert_eq!(refused, (false, preview, preview));
|
||||
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_quoted_list_goes_on_in_source_and_in_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("> - a\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(opened(&document, cx), "> - a\n> - ");
|
||||
cx.simulate_keystrokes("escape");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Source, window, cx);
|
||||
document.set_text("> - a", cx);
|
||||
document.set_caret(0, 5, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(text(&document, cx), "> - a\n> - ");
|
||||
}
|
||||
|
||||
/// A text long enough to be read off the window.
|
||||
fn long() -> String {
|
||||
"para\n\n".repeat(super::super::AT_ONCE / 6 + 1)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_waiting_match_lapses_when_the_text_changes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("x\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_text(long(), cx);
|
||||
document.show_match(0..1, window, cx);
|
||||
});
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_text(format!("y{}", long()), cx)
|
||||
});
|
||||
super::frames(cx);
|
||||
let opened = document.read_with(cx, |document, _| document.open.is_some());
|
||||
assert!(!opened, "the old match does not land on the new text");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_newer_match_takes_the_place_of_a_waiting_one(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("x\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_text(long(), cx);
|
||||
document.show_match(0..1, window, cx);
|
||||
});
|
||||
document.update(cx, |document, cx| document.set_text("a b a\n", cx));
|
||||
cx.run_until_parked();
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.show_match(4..5, window, cx)
|
||||
});
|
||||
super::frames(cx);
|
||||
cx.simulate_input("z");
|
||||
assert_eq!(text(&document, cx), "a b z\n");
|
||||
}
|
||||
Reference in new issue
Block a user