documents: presses read back through quotes, code, HTML and front matter
Each drawn character finds its written one, skipping only white space, escapes and quote marks, so a press lands on the character itself. Code, front matter and link definitions read back line for line. An HTML block keeps each text run's source byte, and an alert keeps its text past the marker; alert() now takes the source. A secondary press refuses a target its write-back changed, and its focus no longer opens an empty document. Run's element stack moves to tree/stack.rs and the HTML-block reader to tree/html_block.rs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5af11fe489
commit
83d747d87e
16 files changed
+868
-480
No files matched your search
@@ -34,6 +34,9 @@ pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<u
|
||||
impl MarkdownDocument {
|
||||
/// An empty, read visual document takes typing at once.
|
||||
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if std::mem::take(&mut self.pointing) {
|
||||
return;
|
||||
}
|
||||
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
|
||||
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
|
||||
self.open(None, Landing::End, window, cx);
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct MarkdownDocument {
|
||||
/// The block, text, typing and heights last centered.
|
||||
centered: Option<(usize, u64, u64, [u64; 2])>,
|
||||
focus: FocusHandle,
|
||||
/// A secondary press took focus.
|
||||
pointing: bool,
|
||||
on_link: Option<OnLink>,
|
||||
on_heading: Option<OnHeading>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -165,6 +167,7 @@ impl MarkdownDocument {
|
||||
centered: None,
|
||||
room: Pixels::ZERO,
|
||||
focus,
|
||||
pointing: false,
|
||||
on_link: None,
|
||||
on_heading: Some(on_heading),
|
||||
_subscriptions: subscriptions,
|
||||
|
||||
@@ -107,7 +107,8 @@ mod tests {
|
||||
]
|
||||
);
|
||||
let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn");
|
||||
assert_eq!(drawn(0), [Node::FrontMatter("title: a".into())]);
|
||||
let text = "title: a".into();
|
||||
assert_eq!(drawn(0), [Node::FrontMatter { text, from: 4 }]);
|
||||
assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x"));
|
||||
let headings: Vec<(u8, &str, usize)> = found
|
||||
.headings
|
||||
|
||||
@@ -6,6 +6,7 @@ use markdown::mdast;
|
||||
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved};
|
||||
use crate::documents::{
|
||||
render::{OnPoint, Place, Pointed},
|
||||
tree::lines_at,
|
||||
tree::{alert, is_toc},
|
||||
};
|
||||
|
||||
@@ -35,7 +36,7 @@ impl TargetBlock {
|
||||
mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc,
|
||||
mdast::Node::Paragraph(_) => Self::Paragraph,
|
||||
mdast::Node::Heading(heading) => Self::Heading(heading.depth),
|
||||
mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert,
|
||||
mdast::Node::Blockquote(quote) if alert(quote, source).is_some() => Self::Alert,
|
||||
mdast::Node::Blockquote(_) => Self::Quote,
|
||||
mdast::Node::List(_) => Self::List,
|
||||
mdast::Node::Code(_) => Self::Code,
|
||||
@@ -73,6 +74,10 @@ impl MarkdownDocument {
|
||||
// The drawing counts from its block's start.
|
||||
let at = match &pointed.place {
|
||||
Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn),
|
||||
// Unreadable lines land at their start.
|
||||
Place::Lines { text, from, at } => {
|
||||
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
||||
}
|
||||
Place::At(at) => Some(*at),
|
||||
Place::Block => None,
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl MarkdownDocument {
|
||||
block => block.flatten(),
|
||||
};
|
||||
let kind = block.map(|block| block.kind);
|
||||
let pressed = block.map(|block| self.parsed.text[block.range.clone()].to_string());
|
||||
let at = at.or(block.map(|block| block.range.start));
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
@@ -119,8 +125,17 @@ impl MarkdownDocument {
|
||||
None => return log::warn!("markdown document: the pressed text was written over"),
|
||||
},
|
||||
};
|
||||
// A write-back may change the pressed block.
|
||||
let now = (self.parsed.blocks.iter())
|
||||
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||
.map(|block| self.parsed.text[block.range.clone()].to_string());
|
||||
if written.is_some() && pressed.is_some() && now != pressed {
|
||||
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||
}
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
// Its focus opens no empty block.
|
||||
self.pointing = !self.focus.is_focused(window);
|
||||
window.focus(&self.focus, cx);
|
||||
log::info!("markdown document: a secondary press puts the caret at byte {at}");
|
||||
cx.emit(DocumentEvent::Pointed(DocumentTarget {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{open, type_in};
|
||||
use crate::{
|
||||
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
|
||||
documents::{
|
||||
DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock,
|
||||
document::parse::parsed,
|
||||
tree::{Inline, Node},
|
||||
},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
@@ -163,3 +167,164 @@ fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) {
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
/// Base and small text sizes, in pixels.
|
||||
fn sizes(cx: &mut VisualTestContext) -> (Pixels, Pixels) {
|
||||
cx.update(|window, cx| {
|
||||
let size = |size| cx.theme().text_size(size).to_pixels(window.rem_size());
|
||||
(size(TextSize::Base), size(TextSize::Sm))
|
||||
})
|
||||
}
|
||||
|
||||
fn press_at(x: Pixels, y: Pixels, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
cx.simulate_mouse_down(point(x, y), button, Modifiers::none());
|
||||
cx.simulate_mouse_up(point(x, y), button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Presses block 0's code glyph `k` on `line`.
|
||||
fn press_lines(k: usize, line: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, small) = sizes(cx);
|
||||
let x = bounds.left() + px(25.0) + small * 0.6 * (k as f32 + 0.25);
|
||||
let y = bounds.top() + px(17.0) + body * 1.6 * (line as f32 + 0.25);
|
||||
press_at(x, y, button, cx);
|
||||
}
|
||||
|
||||
/// The source byte under drawn `shown` in block 0.
|
||||
fn read_back(source: &str, shown: &str) -> Option<usize> {
|
||||
fn find<'a>(nodes: &'a [Node], shown: &str) -> Option<&'a Inline> {
|
||||
nodes.iter().find_map(|node| match node {
|
||||
Node::Paragraph(inline) | Node::Heading(_, inline) if inline.text.contains(shown) => {
|
||||
Some(inline)
|
||||
}
|
||||
Node::Quote(body) | Node::Alert { body, .. } | Node::Footnote { body, .. } => {
|
||||
find(body, shown)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
let parsed = parsed(source);
|
||||
let block = &parsed.blocks[0];
|
||||
let inline = find(block.nodes.as_ref().expect("drawn"), shown).expect("shown");
|
||||
let at = inline.text.find(shown).expect("shown");
|
||||
let written = inline.source_at(&source[block.range.start..], at)?;
|
||||
Some(block.range.start + written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_marks_escapes_trims_and_alert_markers_read_back() {
|
||||
let cases = [
|
||||
("> alpha\n> beta", "eta"),
|
||||
("> [!NOTE]\n> alpha beta", "beta"),
|
||||
("a \\*star\\* z", "*star"),
|
||||
("a ` xy ` z", "xy"),
|
||||
("<div>alpha beta</div>\n", "eta"),
|
||||
("> <div>\n> alpha beta</div>\n", "beta"),
|
||||
];
|
||||
for (source, shown) in cases {
|
||||
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
|
||||
let source = "> alpha\n> beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 7.25;
|
||||
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_an_alert_lands_past_its_marker(cx: &mut TestAppContext) {
|
||||
let source = "> [!NOTE]\n> alpha beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 6.25;
|
||||
let y = bounds.top() + px(15.0) + body * 1.6;
|
||||
press_at(x, y, MouseButton::Right, cx);
|
||||
let at = source.find("beta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Alert)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_html_block_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "<div>alpha beta</div>\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 7, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||
press_on(0, 7, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_definition_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://ely.dev\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 10, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
|
||||
for source in [" alpha\n beta\n", "```\r\nalpha\r\nbeta\r\n```\r\n"] {
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(1, 1, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
let expected = DocumentTarget {
|
||||
in_code: true,
|
||||
..target(at, TargetBlock::Code)
|
||||
};
|
||||
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
||||
press_lines(1, 1, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at, "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_front_matter_press_lands_past_its_fence(cx: &mut TestAppContext) {
|
||||
let source = "---\ntitle: value\n---\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(7, 0, MouseButton::Right, cx);
|
||||
let at = source.find("value").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||
press_lines(7, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_on_an_empty_document_opens_nothing(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
let events = heard(&document, cx);
|
||||
press_at(px(25.0), px(25.0), MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow().len(), 1);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_write_back_that_changes_the_pressed_block_points_nowhere(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("First.\n\nalpha beta\n", MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
type_in(&document, Some(0), Some("```"), cx);
|
||||
press_on(1, 7, MouseButton::Right, cx);
|
||||
let written = "```\n\nalpha beta\n";
|
||||
assert_eq!(super::text(&document, cx), written);
|
||||
assert!(
|
||||
events.borrow().is_empty(),
|
||||
"the pressed paragraph became code"
|
||||
);
|
||||
assert_ne!(caret(&document, cx), written.find("eta").unwrap());
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
|
||||
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
||||
prelude::FluentBuilder as _,
|
||||
};
|
||||
|
||||
use super::{MarkdownDocument, edit::Landing};
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
render::{Hooks, Pointed, draw_nodes},
|
||||
render::{Hooks, Pointed, draw_nodes, point_lines},
|
||||
},
|
||||
forms::{self, Down, Up},
|
||||
primitives::FocusRing as _,
|
||||
@@ -113,11 +114,25 @@ impl MarkdownDocument {
|
||||
self.look.scale,
|
||||
cx,
|
||||
)),
|
||||
None => div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.parsed.text[block.range.clone()].to_string()),
|
||||
None => {
|
||||
let raw = &self.parsed.text[block.range.clone()];
|
||||
let drawn = StyledText::new(raw.to_string());
|
||||
let layout = drawn.layout().clone();
|
||||
let shown = div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(drawn)
|
||||
.into_any_element();
|
||||
div().child(point_lines(
|
||||
shown,
|
||||
layout,
|
||||
raw,
|
||||
0,
|
||||
&on_point,
|
||||
(false, false),
|
||||
))
|
||||
}
|
||||
};
|
||||
let block = div()
|
||||
.id(("block", ix))
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
mod blocks;
|
||||
mod point;
|
||||
|
||||
pub(crate) use point::{OnPoint, Place, Pointed};
|
||||
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
|
||||
|
||||
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
/// Reveals the heading whose source starts at a byte.
|
||||
@@ -264,7 +264,7 @@ impl Draw<'_> {
|
||||
} => {
|
||||
if language.as_deref() == Some("mermaid") {
|
||||
let diagram = Diagram::new(self.key("diagram"), text.clone());
|
||||
return self.point_code(diagram.into_any_element(), None, None);
|
||||
return self.point_code(diagram.into_any_element(), None, *from);
|
||||
}
|
||||
if let Some(draw) = self.code {
|
||||
return draw(
|
||||
@@ -299,7 +299,7 @@ impl Draw<'_> {
|
||||
}))
|
||||
.child(code)
|
||||
.into_any_element();
|
||||
self.point_code(block, Some(layout), *from)
|
||||
self.point_code(block, Some((layout, text)), *from)
|
||||
}
|
||||
Node::List { start, items } => self.list(start, items, cx),
|
||||
Node::Table { aligns, head, rows } => {
|
||||
@@ -410,7 +410,7 @@ impl Draw<'_> {
|
||||
.into_any_element(),
|
||||
Node::Alert { kind, body } => self.alert(*kind, body, cx),
|
||||
Node::Toc(headings) => self.toc(headings, cx),
|
||||
Node::FrontMatter(text) => self.front_matter(text, cx),
|
||||
Node::FrontMatter { text, from } => self.front_matter(text, *from, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use gpui::{
|
||||
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, div, prelude::*,
|
||||
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, StyledText, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::Draw;
|
||||
use super::{Draw, point::point_lines};
|
||||
use crate::{
|
||||
documents::tree::{AlertKind, DocumentHeading, Node},
|
||||
primitives::{FocusRing as _, Icon, IconName},
|
||||
@@ -171,16 +172,26 @@ impl Draw<'_> {
|
||||
}
|
||||
|
||||
/// Front matter as written, inside its box.
|
||||
pub(super) fn front_matter(&self, text: &str, cx: &App) -> AnyElement {
|
||||
pub(super) fn front_matter(&self, text: &str, from: usize, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
let drawn = StyledText::new(text.to_string());
|
||||
let layout = drawn.layout().clone();
|
||||
let block = div()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm) * self.scale)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
.child(drawn)
|
||||
.into_any_element();
|
||||
point_lines(
|
||||
block,
|
||||
layout,
|
||||
text,
|
||||
from,
|
||||
self.hooks.on_point,
|
||||
(false, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ pub(crate) enum Place {
|
||||
Text(Rc<Inline>, usize),
|
||||
/// A source byte.
|
||||
At(usize),
|
||||
/// Drawn byte `at` of `text`, its lines written from `from`.
|
||||
Lines {
|
||||
text: Rc<str>,
|
||||
from: usize,
|
||||
at: usize,
|
||||
},
|
||||
/// Somewhere the block holds no byte for.
|
||||
Block,
|
||||
}
|
||||
@@ -47,36 +53,70 @@ impl Pointed {
|
||||
/// Takes a press's button and what it fell on.
|
||||
pub(crate) type OnPoint = Rc<dyn Fn(Pointed, MouseButton, &mut Window, &mut App)>;
|
||||
|
||||
/// `element` hands its left and right presses to `on_point`.
|
||||
fn pointable(
|
||||
element: AnyElement,
|
||||
on_point: &Option<OnPoint>,
|
||||
table: bool,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> AnyElement {
|
||||
let Some(on_point) = on_point.clone() else {
|
||||
return element;
|
||||
};
|
||||
let point = Rc::new(point);
|
||||
let press = move |button: MouseButton| {
|
||||
let (on_point, point) = (on_point.clone(), point.clone());
|
||||
move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if button == MouseButton::Left && event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let mut pointed = point(event);
|
||||
pointed.table |= table;
|
||||
on_point(pointed, button, window, cx);
|
||||
}
|
||||
};
|
||||
div()
|
||||
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||
.child(element)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Drawn `text`, its lines written from source byte `from`.
|
||||
pub(crate) fn point_lines(
|
||||
element: AnyElement,
|
||||
layout: TextLayout,
|
||||
text: &str,
|
||||
from: usize,
|
||||
on_point: &Option<OnPoint>,
|
||||
(table, code): (bool, bool),
|
||||
) -> AnyElement {
|
||||
let text: Rc<str> = Rc::from(text);
|
||||
pointable(element, on_point, table, move |event| {
|
||||
let at = layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near);
|
||||
Pointed {
|
||||
code,
|
||||
..Pointed::at(Place::Lines {
|
||||
text: text.clone(),
|
||||
from,
|
||||
at,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
/// `element` hands its left and right presses to the owner.
|
||||
fn pointable(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> AnyElement {
|
||||
let Some(on_point) = self.hooks.on_point.clone() else {
|
||||
return element;
|
||||
};
|
||||
let (point, table) = (Rc::new(point), self.table.get());
|
||||
let press = move |button: MouseButton| {
|
||||
let (on_point, point) = (on_point.clone(), point.clone());
|
||||
move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if button == MouseButton::Left && event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let mut pointed = point(event);
|
||||
pointed.table |= table;
|
||||
on_point(pointed, button, window, cx);
|
||||
}
|
||||
};
|
||||
div()
|
||||
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||
.child(element)
|
||||
.into_any_element()
|
||||
pointable(element, self.hooks.on_point, self.table.get(), point)
|
||||
}
|
||||
|
||||
/// Drawn `inline`; a press finds its byte through `layout`.
|
||||
@@ -102,27 +142,28 @@ impl Draw<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A code block; its text starts at source byte `from`.
|
||||
/// A code block; its lines start at source byte `from`.
|
||||
pub(super) fn point_code(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
layout: Option<TextLayout>,
|
||||
from: Option<usize>,
|
||||
drawn: Option<(TextLayout, &str)>,
|
||||
from: usize,
|
||||
) -> AnyElement {
|
||||
self.pointable(element, move |event| {
|
||||
let place = match (&layout, from) {
|
||||
(Some(layout), Some(from)) => Place::At(
|
||||
from + layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near),
|
||||
),
|
||||
_ => Place::Block,
|
||||
};
|
||||
Pointed {
|
||||
let table = self.table.get();
|
||||
match drawn {
|
||||
Some((layout, text)) => point_lines(
|
||||
element,
|
||||
layout,
|
||||
text,
|
||||
from,
|
||||
self.hooks.on_point,
|
||||
(table, true),
|
||||
),
|
||||
None => self.pointable(element, |_| Pointed {
|
||||
code: true,
|
||||
..Pointed::at(place)
|
||||
}
|
||||
})
|
||||
..Pointed::block()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture written at source byte `at`.
|
||||
|
||||
+26
-285
@@ -1,6 +1,6 @@
|
||||
use markdown::mdast;
|
||||
|
||||
use super::{Inline, Node, Reader, Run, html};
|
||||
use super::{Node, Reader};
|
||||
use crate::documents::markdown::span;
|
||||
|
||||
/// A heading anywhere in the file.
|
||||
@@ -69,151 +69,19 @@ impl Reader<'_> {
|
||||
let mdast::Node::Blockquote(quote) = node else {
|
||||
return None;
|
||||
};
|
||||
let (kind, parts) = alert(quote)?;
|
||||
let (kind, parts) = alert(quote, self.source)?;
|
||||
let mut body = Vec::new();
|
||||
self.blocks(&parts, &mut body);
|
||||
Some(Node::Alert { kind, body })
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTML block's text, as a browser shows it.
|
||||
pub(super) fn html_block_text(written: &str) -> Inline {
|
||||
let mut run = Run::default();
|
||||
let mut rest = written;
|
||||
while let Some(at) = rest.find('<') {
|
||||
run.words(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
if let Some(comment) = rest.strip_prefix("<!--") {
|
||||
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||
run.shortcodes.split();
|
||||
continue;
|
||||
}
|
||||
let opens =
|
||||
rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch));
|
||||
if !opens {
|
||||
run.words("<");
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
}
|
||||
// An unclosed tag drops the rest, as HTML5 does.
|
||||
let Some(end) = html::tag_end(rest) else {
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let (written, after) = rest.split_at(end);
|
||||
rest = after;
|
||||
match html::tag(written) {
|
||||
Some(tag) if html::filtered(&tag.name) => run.words(written),
|
||||
// A block closes what opened inside it.
|
||||
Some(tag) if html::breaks(&tag.name) => {
|
||||
run.line();
|
||||
match tag.closing {
|
||||
true => run.end(&tag.name),
|
||||
false => {
|
||||
run.implied(&tag.name);
|
||||
match tag.name == "hr" {
|
||||
true => run.shortcodes.split(),
|
||||
false => run.start(&tag.name, None, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// It ends a `p` but lays no block.
|
||||
Some(tag) if !tag.closing && html::closes_p(&tag.name) => {
|
||||
run.implied(&tag.name);
|
||||
run.html(written);
|
||||
}
|
||||
// A line's end drops its space.
|
||||
Some(tag) if tag.closing || tag.name == "br" => {
|
||||
run.owed = run.owed.filter(|_| tag.name != "br");
|
||||
run.html(written);
|
||||
}
|
||||
Some(_) => {
|
||||
run.pay();
|
||||
run.html(written);
|
||||
}
|
||||
None => run.shortcodes.split(),
|
||||
}
|
||||
}
|
||||
run.words(rest);
|
||||
run.cut()
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// HTML text: references decoded, white space collapsed.
|
||||
fn words(&mut self, written: &str) {
|
||||
let shown = self.shown(&html::text(written));
|
||||
self.spaced(&shown);
|
||||
}
|
||||
|
||||
/// Text with its white space collapsed.
|
||||
fn spaced(&mut self, shown: &str) {
|
||||
for ch in shown.chars() {
|
||||
if !html::spacing(ch) {
|
||||
self.pay();
|
||||
self.text.text.push(ch);
|
||||
} else if self.owed.is_none() && !self.text.text.ends_with('\n') {
|
||||
let open = self.open.len();
|
||||
self.owed = (!self.text.text.is_empty()).then_some((' ', open));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line owed where a block element starts or ends.
|
||||
fn line(&mut self) {
|
||||
let from = self.owed.map_or(self.open.len(), |(_, from)| from);
|
||||
let text = &self.text.text;
|
||||
self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some(('\n', from));
|
||||
}
|
||||
|
||||
/// Writes the white space owed, before a word or tag.
|
||||
fn pay(&mut self) {
|
||||
let Some((owed, from)) = self.owed.take() else {
|
||||
return;
|
||||
};
|
||||
self.text.text.push(owed);
|
||||
let start = self.text.text.len();
|
||||
// What opened after it starts after it.
|
||||
for opened in &mut self.open[from..] {
|
||||
opened.start = start;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends what a block's start tag closes, as HTML5 does.
|
||||
fn implied(&mut self, name: &str) {
|
||||
let items: &[&str] = match name {
|
||||
"li" => &["li"],
|
||||
"dd" | "dt" => &["dd", "dt"],
|
||||
_ => &[],
|
||||
};
|
||||
// The search stops at a special element.
|
||||
let stops = |name: &str| html::breaks(name) && !matches!(name, "address" | "div" | "p");
|
||||
let found = items
|
||||
.iter()
|
||||
.any(|item| self.count(item) > 0)
|
||||
.then(|| {
|
||||
let mut names = self.open.iter().rev().map(|opened| opened.name.as_str());
|
||||
names.find(|name| items.contains(name) || stops(name))
|
||||
})
|
||||
.flatten()
|
||||
.filter(|name| items.contains(name))
|
||||
.map(str::to_string);
|
||||
if let Some(item) = found {
|
||||
self.end(&item);
|
||||
}
|
||||
if html::closes_p(name) && self.count("p") > 0 {
|
||||
self.line();
|
||||
self.end("p");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote's alert kind and its blocks after the marker.
|
||||
pub fn alert(quote: &mdast::Blockquote) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
pub fn alert(quote: &mdast::Blockquote, source: &str) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else {
|
||||
return None;
|
||||
};
|
||||
let (kind, after) = marker(¶graph.children)?;
|
||||
let (kind, after) = marker(¶graph.children, source)?;
|
||||
if after.is_empty() && rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -233,7 +101,7 @@ pub fn is_toc(node: &mdast::Node, source: &str) -> bool {
|
||||
}
|
||||
|
||||
/// A plain first line naming a kind, and what follows.
|
||||
fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
fn marker(children: &[mdast::Node], source: &str) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
let mut line = String::new();
|
||||
for (at, child) in children.iter().enumerate() {
|
||||
let head = match child {
|
||||
@@ -248,6 +116,7 @@ fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
let after = after.strip_prefix("\r\n").unwrap_or(&after[1..]);
|
||||
let mut kept = text.clone();
|
||||
kept.value = after.to_string();
|
||||
past_line(&mut kept, source);
|
||||
(!after.is_empty()).then_some(mdast::Node::Text(kept))
|
||||
}
|
||||
},
|
||||
@@ -260,10 +129,29 @@ fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
Some((AlertKind::marked(&line)?, Vec::new()))
|
||||
}
|
||||
|
||||
/// `text` starts past its first line, as its value does.
|
||||
fn past_line(text: &mut mdast::Text, source: &str) {
|
||||
let start = &mut text
|
||||
.position
|
||||
.as_mut()
|
||||
.expect("parsed text has a place")
|
||||
.start;
|
||||
let written = &source[start.offset..];
|
||||
let end = written.find(['\n', '\r']).expect("its line end is written");
|
||||
let width = if written[end..].starts_with("\r\n") {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
start.offset += end + width;
|
||||
start.line += 1;
|
||||
start.column = 1;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::documents::tree::{Inline, Style, parse};
|
||||
use crate::documents::tree::{Style, parse};
|
||||
|
||||
fn alert(source: &str) -> Option<(AlertKind, Vec<Node>)> {
|
||||
match parse(source).into_iter().next() {
|
||||
@@ -345,151 +233,4 @@ mod tests {
|
||||
assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_)));
|
||||
assert!(matches!(&parse("> [toc]")[0], Node::Quote(_)));
|
||||
}
|
||||
|
||||
fn html(source: &str) -> Option<Inline> {
|
||||
match parse(source).into_iter().next() {
|
||||
Some(Node::Paragraph(line)) => Some(line),
|
||||
None => None,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text_as_a_browser_does() {
|
||||
let centered = html(
|
||||
"<div align=\"center\">\n <b>Bold</b> and\n :smile: & <code>:smile:</code>\n</div>",
|
||||
)
|
||||
.expect("text");
|
||||
assert_eq!(centered.text, "Bold and 😄 & :smile:");
|
||||
let (bold, code) = (0..4, 16..23);
|
||||
assert_eq!(
|
||||
centered.styles,
|
||||
[(bold, Style::Strong), (code, Style::Code)]
|
||||
);
|
||||
let parts = html("<p>one <b>two </b></p>\n<p>three <b></p>").expect("text");
|
||||
assert_eq!(parts.text, "one two\nthree <b>");
|
||||
assert_eq!(
|
||||
html("<div>\na <br> b</br>c</div>").expect("text").text,
|
||||
"a\nb\nc"
|
||||
);
|
||||
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
|
||||
assert_eq!(
|
||||
html(filtered).expect("text").text,
|
||||
"<script>x</script> shown<style>p{}</style>"
|
||||
);
|
||||
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
|
||||
assert_eq!(
|
||||
html(crossed).expect("text").text,
|
||||
"before<script>ab</script>after"
|
||||
);
|
||||
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
|
||||
assert_eq!(text(&split), ["<script> first", "second </script>"]);
|
||||
let inline = html("a <script>b</script> c").expect("text");
|
||||
assert_eq!(inline.text, "a <script>b</script> c");
|
||||
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_block_ends_what_opened_inside_it() {
|
||||
let marked = html("<div><mark>x</div>y").expect("text");
|
||||
assert_eq!(
|
||||
(marked.text.as_str(), &marked.styles[..]),
|
||||
("x\ny", &[(0..1, Style::Mark)][..])
|
||||
);
|
||||
let bold = html("<div><b>x</div>y").expect("text");
|
||||
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
|
||||
let kept = html("<div><mark><p>x</p>y</mark></div>").expect("text");
|
||||
assert_eq!(kept.styles, [(0..3, Style::Mark)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_start_ends_what_html5_ends() {
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
for source in [
|
||||
"<ul><li><mark>x<li>y</ul>z",
|
||||
"<div><p><mark>x<div>y</div>z</div>",
|
||||
"<dl><dt><mark>x<dd>y</dl>z",
|
||||
] {
|
||||
let line = html(source).expect("text");
|
||||
assert_eq!(
|
||||
(line.text.as_str(), &line.styles[..]),
|
||||
("x\ny\nz", &mark[..])
|
||||
);
|
||||
}
|
||||
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
|
||||
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
|
||||
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").expect("text");
|
||||
assert_eq!(
|
||||
nested.styles,
|
||||
[(0..3, Style::Mark)],
|
||||
"a list stops the search"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_tags_are_text_and_names_end_as_html5_ends_them() {
|
||||
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||
for (source, shown) in [
|
||||
(
|
||||
"<div><script>:smile:</script> :smile:</div>",
|
||||
"<script>:smile:</script> :smile:",
|
||||
),
|
||||
(
|
||||
"<div><script><b>:smile:</b></script> :smile:</div>",
|
||||
"<script>😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script> :smile:</script> :smile:</div>",
|
||||
"<script> 😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script title=\" :smile:\"> x:smile:</div>",
|
||||
"<script title=\" 😄\"> x😄",
|
||||
),
|
||||
(
|
||||
"<div><iframe title=\":smile:\"> :smile:</div>",
|
||||
"<iframe title=\":smile:\"> :smile:",
|
||||
),
|
||||
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
|
||||
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
|
||||
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
|
||||
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
|
||||
("<div>:smile:</div>", "😄"),
|
||||
] {
|
||||
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
|
||||
}
|
||||
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
|
||||
assert_eq!(html(named).expect("text").text, "axbyc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_html5_p_closer_ends_a_p() {
|
||||
for name in ["fieldset", "menu"] {
|
||||
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_html_reads_in_linear_time() {
|
||||
let started = web_time::Instant::now();
|
||||
for source in [
|
||||
"<".repeat(1 << 17),
|
||||
"<a".repeat(1 << 16),
|
||||
"<!--".repeat(1 << 16),
|
||||
"<b>".repeat(1 << 16),
|
||||
"<b>".repeat(1 << 15) + &"</i>".repeat(1 << 15),
|
||||
"<script></script>".repeat(1 << 13),
|
||||
"<div>a ".repeat(1 << 16),
|
||||
"<b> ".repeat(1 << 16),
|
||||
"<li><b>x ".repeat(1 << 15),
|
||||
"<p><i>x ".repeat(1 << 15),
|
||||
] {
|
||||
let block = format!("<div>\n{source}");
|
||||
parse(&block);
|
||||
}
|
||||
let spent = started.elapsed();
|
||||
assert!(spent.as_secs() < 2, "took {spent:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::{Inline, Run, html, sources::Follow};
|
||||
|
||||
/// An HTML block's text, as a browser shows it.
|
||||
pub(super) fn html_block_text(written: &str, source: &str, span: Range<usize>) -> Inline {
|
||||
let (mut run, mut follow) = (Run::default(), Follow::new(written, source, span.clone()));
|
||||
let mut rest = written;
|
||||
// Each text run keeps where it was written.
|
||||
let mut words = |run: &mut Run, rest: &str, text: &str| {
|
||||
if let Some(at) = follow.to(written.len() - rest.len()) {
|
||||
run.text.sources.push((run.text.text.len(), at..span.end));
|
||||
}
|
||||
run.words(text);
|
||||
};
|
||||
while let Some(at) = rest.find('<') {
|
||||
words(&mut run, rest, &rest[..at]);
|
||||
rest = &rest[at..];
|
||||
if let Some(comment) = rest.strip_prefix("<!--") {
|
||||
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||
run.shortcodes.split();
|
||||
continue;
|
||||
}
|
||||
let opens =
|
||||
rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch));
|
||||
if !opens {
|
||||
words(&mut run, rest, "<");
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
}
|
||||
// An unclosed tag drops the rest, as HTML5 does.
|
||||
let Some(end) = html::tag_end(rest) else {
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let (tag_text, after) = rest.split_at(end);
|
||||
match html::tag(tag_text) {
|
||||
Some(tag) if html::filtered(&tag.name) => words(&mut run, rest, tag_text),
|
||||
// A block closes what opened inside it.
|
||||
Some(tag) if html::breaks(&tag.name) => {
|
||||
run.line();
|
||||
match tag.closing {
|
||||
true => run.end(&tag.name),
|
||||
false => {
|
||||
run.implied(&tag.name);
|
||||
match tag.name == "hr" {
|
||||
true => run.shortcodes.split(),
|
||||
false => run.start(&tag.name, None, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// It ends a `p` but lays no block.
|
||||
Some(tag) if !tag.closing && html::closes_p(&tag.name) => {
|
||||
run.implied(&tag.name);
|
||||
run.html(tag_text);
|
||||
}
|
||||
// A line's end drops its space.
|
||||
Some(tag) if tag.closing || tag.name == "br" => {
|
||||
run.owed = run.owed.filter(|_| tag.name != "br");
|
||||
run.html(tag_text);
|
||||
}
|
||||
Some(_) => {
|
||||
run.pay();
|
||||
run.html(tag_text);
|
||||
}
|
||||
None => run.shortcodes.split(),
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
words(&mut run, rest, rest);
|
||||
run.cut()
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// HTML text: references decoded, white space collapsed.
|
||||
fn words(&mut self, written: &str) {
|
||||
let shown = self.shown(&html::text(written));
|
||||
self.spaced(&shown);
|
||||
}
|
||||
|
||||
/// Text with its white space collapsed.
|
||||
fn spaced(&mut self, shown: &str) {
|
||||
for ch in shown.chars() {
|
||||
if !html::spacing(ch) {
|
||||
self.pay();
|
||||
self.text.text.push(ch);
|
||||
} else if self.owed.is_none() && !self.text.text.ends_with('\n') {
|
||||
let open = self.open.len();
|
||||
self.owed = (!self.text.text.is_empty()).then_some((' ', open));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line owed where a block element starts or ends.
|
||||
fn line(&mut self) {
|
||||
let from = self.owed.map_or(self.open.len(), |(_, from)| from);
|
||||
let text = &self.text.text;
|
||||
self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some(('\n', from));
|
||||
}
|
||||
|
||||
/// Writes the white space owed, before a word or tag.
|
||||
fn pay(&mut self) {
|
||||
let Some((owed, from)) = self.owed.take() else {
|
||||
return;
|
||||
};
|
||||
self.text.text.push(owed);
|
||||
let start = self.text.text.len();
|
||||
// What opened after it starts after it.
|
||||
for opened in &mut self.open[from..] {
|
||||
opened.start = start;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends what a block's start tag closes, as HTML5 does.
|
||||
fn implied(&mut self, name: &str) {
|
||||
let items: &[&str] = match name {
|
||||
"li" => &["li"],
|
||||
"dd" | "dt" => &["dd", "dt"],
|
||||
_ => &[],
|
||||
};
|
||||
// The search stops at a special element.
|
||||
let stops = |name: &str| html::breaks(name) && !matches!(name, "address" | "div" | "p");
|
||||
let found = items
|
||||
.iter()
|
||||
.any(|item| self.count(item) > 0)
|
||||
.then(|| {
|
||||
let mut names = self.open.iter().rev().map(|opened| opened.name.as_str());
|
||||
names.find(|name| items.contains(name) || stops(name))
|
||||
})
|
||||
.flatten()
|
||||
.filter(|name| items.contains(name))
|
||||
.map(str::to_string);
|
||||
if let Some(item) = found {
|
||||
self.end(&item);
|
||||
}
|
||||
if html::closes_p(name) && self.count("p") > 0 {
|
||||
self.line();
|
||||
self.end("p");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::documents::tree::{Inline, Node, Style, parse};
|
||||
|
||||
fn html(source: &str) -> Option<Inline> {
|
||||
match parse(source).into_iter().next() {
|
||||
Some(Node::Paragraph(line)) => Some(line),
|
||||
None => None,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text_as_a_browser_does() {
|
||||
let centered = html(
|
||||
"<div align=\"center\">\n <b>Bold</b> and\n :smile: & <code>:smile:</code>\n</div>",
|
||||
)
|
||||
.expect("text");
|
||||
assert_eq!(centered.text, "Bold and 😄 & :smile:");
|
||||
let (bold, code) = (0..4, 16..23);
|
||||
assert_eq!(
|
||||
centered.styles,
|
||||
[(bold, Style::Strong), (code, Style::Code)]
|
||||
);
|
||||
let parts = html("<p>one <b>two </b></p>\n<p>three <b></p>").expect("text");
|
||||
assert_eq!(parts.text, "one two\nthree <b>");
|
||||
assert_eq!(
|
||||
html("<div>\na <br> b</br>c</div>").expect("text").text,
|
||||
"a\nb\nc"
|
||||
);
|
||||
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
|
||||
assert_eq!(
|
||||
html(filtered).expect("text").text,
|
||||
"<script>x</script> shown<style>p{}</style>"
|
||||
);
|
||||
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
|
||||
assert_eq!(
|
||||
html(crossed).expect("text").text,
|
||||
"before<script>ab</script>after"
|
||||
);
|
||||
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
|
||||
let lines: Vec<_> = (split.into_iter())
|
||||
.map(|node| match node {
|
||||
Node::Paragraph(line) => line.text,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines, ["<script> first", "second </script>"]);
|
||||
let inline = html("a <script>b</script> c").expect("text");
|
||||
assert_eq!(inline.text, "a <script>b</script> c");
|
||||
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_block_ends_what_opened_inside_it() {
|
||||
let marked = html("<div><mark>x</div>y").expect("text");
|
||||
assert_eq!(
|
||||
(marked.text.as_str(), &marked.styles[..]),
|
||||
("x\ny", &[(0..1, Style::Mark)][..])
|
||||
);
|
||||
let bold = html("<div><b>x</div>y").expect("text");
|
||||
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
|
||||
let kept = html("<div><mark><p>x</p>y</mark></div>").expect("text");
|
||||
assert_eq!(kept.styles, [(0..3, Style::Mark)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_start_ends_what_html5_ends() {
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
for source in [
|
||||
"<ul><li><mark>x<li>y</ul>z",
|
||||
"<div><p><mark>x<div>y</div>z</div>",
|
||||
"<dl><dt><mark>x<dd>y</dl>z",
|
||||
] {
|
||||
let line = html(source).expect("text");
|
||||
assert_eq!(
|
||||
(line.text.as_str(), &line.styles[..]),
|
||||
("x\ny\nz", &mark[..])
|
||||
);
|
||||
}
|
||||
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
|
||||
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
|
||||
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").expect("text");
|
||||
assert_eq!(
|
||||
nested.styles,
|
||||
[(0..3, Style::Mark)],
|
||||
"a list stops the search"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_tags_are_text_and_names_end_as_html5_ends_them() {
|
||||
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||
for (source, shown) in [
|
||||
(
|
||||
"<div><script>:smile:</script> :smile:</div>",
|
||||
"<script>:smile:</script> :smile:",
|
||||
),
|
||||
(
|
||||
"<div><script><b>:smile:</b></script> :smile:</div>",
|
||||
"<script>😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script> :smile:</script> :smile:</div>",
|
||||
"<script> 😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script title=\" :smile:\"> x:smile:</div>",
|
||||
"<script title=\" 😄\"> x😄",
|
||||
),
|
||||
(
|
||||
"<div><iframe title=\":smile:\"> :smile:</div>",
|
||||
"<iframe title=\":smile:\"> :smile:",
|
||||
),
|
||||
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
|
||||
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
|
||||
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
|
||||
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
|
||||
("<div>:smile:</div>", "😄"),
|
||||
] {
|
||||
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
|
||||
}
|
||||
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
|
||||
assert_eq!(html(named).expect("text").text, "axbyc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_html5_p_closer_ends_a_p() {
|
||||
for name in ["fieldset", "menu"] {
|
||||
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_html_reads_in_linear_time() {
|
||||
let started = web_time::Instant::now();
|
||||
for source in [
|
||||
"<".repeat(1 << 17),
|
||||
"<a".repeat(1 << 16),
|
||||
"<!--".repeat(1 << 16),
|
||||
"<b>".repeat(1 << 16),
|
||||
"<b>".repeat(1 << 15) + &"</i>".repeat(1 << 15),
|
||||
"<script></script>".repeat(1 << 13),
|
||||
"<div>a ".repeat(1 << 16),
|
||||
"<b> ".repeat(1 << 16),
|
||||
"<li><b>x ".repeat(1 << 15),
|
||||
"<p><i>x ".repeat(1 << 15),
|
||||
] {
|
||||
let block = format!("<div>\n{source}");
|
||||
parse(&block);
|
||||
}
|
||||
let spent = started.elapsed();
|
||||
assert!(spent.as_secs() < 2, "took {spent:?}");
|
||||
}
|
||||
}
|
||||
+21
-94
@@ -7,11 +7,14 @@ use super::markdown::{span, tree, walk};
|
||||
mod blocks;
|
||||
mod emoji;
|
||||
mod html;
|
||||
mod html_block;
|
||||
mod sources;
|
||||
mod stack;
|
||||
|
||||
use blocks::html_block_text;
|
||||
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
|
||||
use html::InlineTag;
|
||||
use html_block::html_block_text;
|
||||
pub(crate) use sources::lines_at;
|
||||
use stack::close;
|
||||
|
||||
/// How a stretch of inline text prints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -47,8 +50,8 @@ pub enum Node {
|
||||
Code {
|
||||
language: Option<String>,
|
||||
text: String,
|
||||
/// Where `text` starts in the source, if written as drawn.
|
||||
from: Option<usize>,
|
||||
/// Where its lines start in the source.
|
||||
from: usize,
|
||||
},
|
||||
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
|
||||
List {
|
||||
@@ -82,8 +85,11 @@ pub enum Node {
|
||||
},
|
||||
/// `[toc]`: the file's headings.
|
||||
Toc(Vec<DocumentHeading>),
|
||||
/// Front matter, its fences cut.
|
||||
FrontMatter(String),
|
||||
/// Front matter, its fences cut; lines start at `from`.
|
||||
FrontMatter {
|
||||
text: String,
|
||||
from: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tree read against its source and its link definitions.
|
||||
@@ -153,90 +159,6 @@ impl Run {
|
||||
let soft = shown.replace("\r\n", " ").replace(['\r', '\n'], " ");
|
||||
self.text.text.push_str(&soft);
|
||||
}
|
||||
|
||||
fn push(&mut self, opened: Opened) {
|
||||
*self.named.entry(opened.name.clone()).or_default() += 1;
|
||||
self.open.push(opened);
|
||||
}
|
||||
|
||||
/// The innermost open link's address.
|
||||
fn link(&self) -> Option<String> {
|
||||
self.open.iter().rev().find_map(|opened| opened.url.clone())
|
||||
}
|
||||
|
||||
/// Opens or closes an element for an inline HTML tag.
|
||||
fn html(&mut self, written: &str) {
|
||||
match html::inline_tag(written) {
|
||||
InlineTag::Text(shown) => self.push_text(&shown),
|
||||
InlineTag::Open { name, style, href } => self.start(&name, style, href),
|
||||
InlineTag::Close(name) => {
|
||||
self.shortcodes.split();
|
||||
self.end(&name);
|
||||
}
|
||||
InlineTag::Break => {
|
||||
self.shortcodes.split();
|
||||
self.text.text.push('\n');
|
||||
}
|
||||
InlineTag::Gone => self.shortcodes.split(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `name`; a new `a` ends the open one.
|
||||
fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
|
||||
if name == "a" {
|
||||
self.end("a");
|
||||
}
|
||||
self.shortcodes.split();
|
||||
self.push(Opened {
|
||||
name: name.to_string(),
|
||||
start: self.text.text.len(),
|
||||
style,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends the innermost `name`, as HTML5 repairs nesting.
|
||||
fn end(&mut self, name: &str) {
|
||||
if self.count(name) == 0 {
|
||||
return;
|
||||
}
|
||||
self.shortcodes.split();
|
||||
let at = self
|
||||
.open
|
||||
.iter()
|
||||
.rposition(|opened| opened.name == name)
|
||||
.expect("a counted name is open");
|
||||
let end = self.text.text.len();
|
||||
if let Some((_, from)) = &mut self.owed {
|
||||
*from = (*from).min(at);
|
||||
}
|
||||
let closed = self.open.split_off(at);
|
||||
for opened in &closed {
|
||||
close(&mut self.text, opened, end);
|
||||
*self.named.get_mut(&opened.name).expect("counted") -= 1;
|
||||
}
|
||||
for opened in closed.into_iter().skip(1) {
|
||||
if html::reopens(&opened.name) {
|
||||
self.push(Opened {
|
||||
start: end,
|
||||
..opened
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `opened` as styled up to `end`.
|
||||
fn close(text: &mut Inline, opened: &Opened, end: usize) {
|
||||
if opened.start >= end {
|
||||
return;
|
||||
}
|
||||
if let Some(style) = opened.style {
|
||||
text.styles.push((opened.start..end, style));
|
||||
}
|
||||
if let Some(url) = &opened.url {
|
||||
text.links.push((opened.start..end, url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `markdown` as blocks.
|
||||
@@ -322,7 +244,7 @@ impl<'a> Reader<'a> {
|
||||
mdast::Node::Code(code) => out.push(Node::Code {
|
||||
language: code.lang.clone(),
|
||||
text: code.value.clone(),
|
||||
from: sources::code_from(self.source, node, &code.value),
|
||||
from: sources::body(self.source, node),
|
||||
}),
|
||||
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
|
||||
mdast::Node::List(list) => {
|
||||
@@ -363,9 +285,14 @@ impl<'a> Reader<'a> {
|
||||
label: self.label(note),
|
||||
body: self.nested(¬e.children),
|
||||
}),
|
||||
mdast::Node::Html(html) => text_block(html_block_text(&html.value), out),
|
||||
mdast::Node::Yaml(yaml) => out.push(Node::FrontMatter(yaml.value.clone())),
|
||||
mdast::Node::Toml(toml) => out.push(Node::FrontMatter(toml.value.clone())),
|
||||
mdast::Node::Html(html) => {
|
||||
text_block(html_block_text(&html.value, self.source, span(node)), out)
|
||||
}
|
||||
mdast::Node::Yaml(mdast::Yaml { value, .. })
|
||||
| mdast::Node::Toml(mdast::Toml { value, .. }) => out.push(Node::FrontMatter {
|
||||
text: value.clone(),
|
||||
from: sources::body(self.source, node),
|
||||
}),
|
||||
mdast::Node::Definition(_) => {}
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
|
||||
+116
-38
@@ -1,4 +1,4 @@
|
||||
use std::ops::Range;
|
||||
use std::{iter::Peekable, ops::Range, str::CharIndices};
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
@@ -27,14 +27,36 @@ pub(super) fn written(node: &Node, source: &str) -> Option<Range<usize>> {
|
||||
Some(whole.start + marks..whole.end)
|
||||
}
|
||||
|
||||
/// Where a fenced block's `text` starts, if written as drawn.
|
||||
pub(super) fn code_from(source: &str, node: &Node, text: &str) -> Option<usize> {
|
||||
/// Where a block's text lines start: past a fence line.
|
||||
pub(super) fn body(source: &str, node: &Node) -> usize {
|
||||
let whole = span(node);
|
||||
let written = &source[whole.clone()];
|
||||
let body = written.find('\n')? + 1;
|
||||
written[body..]
|
||||
.starts_with(text)
|
||||
.then_some(whole.start + body)
|
||||
let fenced = matches!(node, Node::Yaml(_) | Node::Toml(_))
|
||||
|| ["```", "~~~"]
|
||||
.iter()
|
||||
.any(|fence| written.starts_with(fence));
|
||||
match fenced {
|
||||
true => written
|
||||
.find('\n')
|
||||
.map_or(whole.end, |end| whole.start + end + 1),
|
||||
false => whole.start,
|
||||
}
|
||||
}
|
||||
|
||||
/// Source byte under drawn byte `at`; lines start at `from`.
|
||||
pub(crate) fn lines_at(source: &str, from: usize, drawn: &str, at: usize) -> Option<usize> {
|
||||
let line = drawn[..at].matches('\n').count();
|
||||
let column = at - drawn[..at].rfind('\n').map_or(0, |end| end + 1);
|
||||
let shown = drawn.split('\n').nth(line)?;
|
||||
let mut start = from;
|
||||
for _ in 0..line {
|
||||
start += source.get(start..)?.find('\n')? + 1;
|
||||
}
|
||||
let written = source[start..].split('\n').next()?.trim_end_matches('\r');
|
||||
// Each drawn line ends its written one.
|
||||
written
|
||||
.ends_with(shown)
|
||||
.then(|| start + written.len() - shown.len() + column)
|
||||
}
|
||||
|
||||
impl Drawn {
|
||||
@@ -58,19 +80,15 @@ impl Drawn {
|
||||
.chain(rows.iter_mut().flatten())
|
||||
.flatten()
|
||||
.for_each(|node| node.rebase(start)),
|
||||
Drawn::Code { from, .. } => {
|
||||
if let Some(from) = from {
|
||||
*from -= start;
|
||||
}
|
||||
}
|
||||
Drawn::Code { from, .. } | Drawn::FrontMatter { from, .. } => *from -= start,
|
||||
Drawn::Image { at, .. } => *at -= start,
|
||||
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) | Drawn::FrontMatter(_) => {}
|
||||
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Inline {
|
||||
/// Source byte under drawn byte `at`, if a piece holds it.
|
||||
/// Source byte under drawn byte `at`, in some piece.
|
||||
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||
let piece = self.sources.partition_point(|(start, _)| *start <= at);
|
||||
let (start, span) = self.sources.get(piece.checked_sub(1)?)?;
|
||||
@@ -80,40 +98,100 @@ impl Inline {
|
||||
.map_or(self.text.len(), |(next, _)| *next);
|
||||
let drawn = self.text.get(*start..end.max(*start))?;
|
||||
let written = source.get(span.clone())?;
|
||||
// Text that does not read back lands at its start.
|
||||
// Unreadable text lands at its start.
|
||||
Some(span.start + align(drawn, written, at - start).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where drawn byte `at` was written, past joins and escapes.
|
||||
/// Where drawn byte `at` was written, past marks.
|
||||
fn align(drawn: &str, written: &str, at: usize) -> Option<usize> {
|
||||
let (mut shown, mut source) = (drawn.char_indices().peekable(), written.char_indices());
|
||||
let mut next = source.next();
|
||||
loop {
|
||||
let here = next.map_or(written.len(), |(byte, _)| byte);
|
||||
let Some(&(byte, shown_char)) = shown.peek() else {
|
||||
return Some(here);
|
||||
};
|
||||
let mut reading = Reading::new(written, 0);
|
||||
for (byte, shown) in drawn.char_indices() {
|
||||
let found = reading.take(shown)?;
|
||||
if byte >= at {
|
||||
return Some(here);
|
||||
return Some(found);
|
||||
}
|
||||
let (_, written_char) = next?;
|
||||
match (shown_char, written_char) {
|
||||
(a, b) if a == b => {
|
||||
shown.next();
|
||||
}
|
||||
(' ', '\r' | '\n') => {
|
||||
shown.next();
|
||||
next = source.next();
|
||||
while let Some((_, ' ' | '\t' | '\r' | '\n')) = next {
|
||||
next = source.next();
|
||||
}
|
||||
Some(reading.end)
|
||||
}
|
||||
|
||||
/// Finds drawn chars, front to back, in written text.
|
||||
struct Reading<'a> {
|
||||
written: Peekable<CharIndices<'a>>,
|
||||
base: usize,
|
||||
/// Past the last char found.
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reading<'a> {
|
||||
fn new(written: &'a str, base: usize) -> Self {
|
||||
Self {
|
||||
written: written.char_indices().peekable(),
|
||||
base,
|
||||
end: base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `shown` was written; true if a char holds it.
|
||||
fn at(&mut self, shown: char) -> Option<(usize, bool)> {
|
||||
loop {
|
||||
let Some(&(byte, written)) = self.written.peek() else {
|
||||
return shown.is_whitespace().then_some((self.end, false));
|
||||
};
|
||||
let byte = self.base + byte;
|
||||
match (shown, written) {
|
||||
(a, b) if a == b => return Some((byte, true)),
|
||||
(a, b) if a.is_whitespace() => return Some((byte, b.is_whitespace())),
|
||||
// Joins, indents, escapes and quote marks.
|
||||
(_, ' ' | '\t' | '\r' | '\n' | '\\' | '>') => {
|
||||
self.written.next();
|
||||
}
|
||||
continue;
|
||||
_ => return None,
|
||||
}
|
||||
(_, ' ' | '\t' | '\r' | '\n' | '\\') => {}
|
||||
_ => return None,
|
||||
}
|
||||
next = source.next();
|
||||
}
|
||||
|
||||
fn take(&mut self, shown: char) -> Option<usize> {
|
||||
let (byte, held) = self.at(shown)?;
|
||||
if held {
|
||||
let (_, written) = self.written.next().expect("a held char");
|
||||
self.end = byte + written.len_utf8();
|
||||
}
|
||||
Some(byte)
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's value bytes mapped to source, front to back.
|
||||
pub(super) struct Follow<'a> {
|
||||
value: &'a str,
|
||||
done: usize,
|
||||
reading: Option<Reading<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Follow<'a> {
|
||||
pub(super) fn new(value: &'a str, source: &'a str, span: Range<usize>) -> Self {
|
||||
let reading = Reading::new(&source[span.clone()], span.start);
|
||||
Self {
|
||||
value,
|
||||
done: 0,
|
||||
reading: Some(reading),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where value byte `at` was written.
|
||||
pub(super) fn to(&mut self, at: usize) -> Option<usize> {
|
||||
let reading = self.reading.as_mut()?;
|
||||
let lost = self.value[self.done..at]
|
||||
.chars()
|
||||
.any(|shown| reading.take(shown).is_none());
|
||||
self.done = at;
|
||||
if lost {
|
||||
log::warn!("markdown: a block's text lost its source");
|
||||
self.reading = None;
|
||||
return None;
|
||||
}
|
||||
let next = self.value[at..].chars().next()?;
|
||||
self.reading.as_mut()?.at(next).map(|(byte, _)| byte)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use super::{Inline, Opened, Run, Style, html, html::InlineTag};
|
||||
|
||||
/// The elements open around inline text.
|
||||
impl Run {
|
||||
pub(super) fn push(&mut self, opened: Opened) {
|
||||
*self.named.entry(opened.name.clone()).or_default() += 1;
|
||||
self.open.push(opened);
|
||||
}
|
||||
|
||||
/// The innermost open link's address.
|
||||
pub(super) fn link(&self) -> Option<String> {
|
||||
self.open.iter().rev().find_map(|opened| opened.url.clone())
|
||||
}
|
||||
|
||||
/// Opens or closes an element for an inline HTML tag.
|
||||
pub(super) fn html(&mut self, written: &str) {
|
||||
match html::inline_tag(written) {
|
||||
InlineTag::Text(shown) => self.push_text(&shown),
|
||||
InlineTag::Open { name, style, href } => self.start(&name, style, href),
|
||||
InlineTag::Close(name) => {
|
||||
self.shortcodes.split();
|
||||
self.end(&name);
|
||||
}
|
||||
InlineTag::Break => {
|
||||
self.shortcodes.split();
|
||||
self.text.text.push('\n');
|
||||
}
|
||||
InlineTag::Gone => self.shortcodes.split(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `name`; a new `a` ends the open one.
|
||||
pub(super) fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
|
||||
if name == "a" {
|
||||
self.end("a");
|
||||
}
|
||||
self.shortcodes.split();
|
||||
self.push(Opened {
|
||||
name: name.to_string(),
|
||||
start: self.text.text.len(),
|
||||
style,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends the innermost `name`, as HTML5 repairs nesting.
|
||||
pub(super) fn end(&mut self, name: &str) {
|
||||
if self.count(name) == 0 {
|
||||
return;
|
||||
}
|
||||
self.shortcodes.split();
|
||||
let at = self
|
||||
.open
|
||||
.iter()
|
||||
.rposition(|opened| opened.name == name)
|
||||
.expect("a counted name is open");
|
||||
let end = self.text.text.len();
|
||||
if let Some((_, from)) = &mut self.owed {
|
||||
*from = (*from).min(at);
|
||||
}
|
||||
let closed = self.open.split_off(at);
|
||||
for opened in &closed {
|
||||
close(&mut self.text, opened, end);
|
||||
*self.named.get_mut(&opened.name).expect("counted") -= 1;
|
||||
}
|
||||
for opened in closed.into_iter().skip(1) {
|
||||
if html::reopens(&opened.name) {
|
||||
self.push(Opened {
|
||||
start: end,
|
||||
..opened
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `opened` as styled up to `end`.
|
||||
pub(super) fn close(text: &mut Inline, opened: &Opened, end: usize) {
|
||||
if opened.start >= end {
|
||||
return;
|
||||
}
|
||||
if let Some(style) = opened.style {
|
||||
text.styles.push((opened.start..end, style));
|
||||
}
|
||||
if let Some(url) = &opened.url {
|
||||
text.links.push((opened.start..end, url.clone()));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn blocks_nest_as_written() {
|
||||
Node::Code {
|
||||
language: Some("rust".into()),
|
||||
text: "fn main() {}".into(),
|
||||
from: Some(95)
|
||||
from: 95
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes[5], Node::Rule);
|
||||
|
||||
Reference in new issue
Block a user