| @@ -0,0 +1,1056 @@ |
| 1 | + //! The rich text composer: Markdown styled live as you type. |
| 2 | + //! |
| 3 | + //! The text stays plain Markdown (that is what gets sent); only its styling |
| 4 | + //! changes. Headings and `**strong**` turn bold, `*emphasis*` italic, code |
| 5 | + //! sits on a faint well, quotes and fenced blocks are muted, list markers, |
| 6 | + //! links and a leading `/command` or `$skill` are tinted, and the syntax |
| 7 | + //! characters themselves fade back. |
| 8 | + //! |
| 9 | + //! gpui-base only paints highlighter runs in its code-editor layout, so the |
| 10 | + //! rich composer is an [`EditorState`] dressed as the plain composer: no |
| 11 | + //! gutter, folding, indent guides, auto-closing or smart indent, soft wrap on, |
| 12 | + //! and [`MarkdownInput`] grows it from two to twelve rows the way |
| 13 | + //! `TextareaState::auto_grow` does. [`MarkdownHighlighter`] supplies the runs. |
| 14 | + //! |
| 15 | + //! ```ignore |
| 16 | + //! let composer = cx.new(|cx| { |
| 17 | + //! markdown_input::composer_state(EditorState::new(window, cx), rich) |
| 18 | + //! .submit_on_enter(true) |
| 19 | + //! .placeholder("Ask for changes") |
| 20 | + //! }); |
| 21 | + //! // render: MarkdownInput::new(&composer) |
| 22 | + //! // toggle: markdown_input::enable(&composer, on, cx) |
| 23 | + //! ``` |
| 24 | + |
| 25 | + use std::{cell::Cell, ops::Range, rc::Rc}; |
| 26 | + |
| 27 | + use gpui_kit::component::input::{ |
| 28 | + self, Editor, EditorState, FoldRange, HighlightStyleResolver, InputEdit, InputHighlighter, |
| 29 | + InputHighlighterFactory, Rope, RopeExt as _, |
| 30 | + }; |
| 31 | + use gpui_kit::component::native_menu::NativeMenu; |
| 32 | + use gpui_kit::*; |
| 33 | + |
| 34 | + use crate::ui::metal::{self, Palette}; |
| 35 | + |
| 36 | + /// What a byte of Markdown is, as bit flags; a byte can be several at once |
| 37 | + /// (bold inside a heading, a delimiter of inline code). |
| 38 | + mod flag { |
| 39 | + pub const HEADING: u16 = 1 << 0; |
| 40 | + pub const STRONG: u16 = 1 << 1; |
| 41 | + pub const EMPHASIS: u16 = 1 << 2; |
| 42 | + /// Inline code, delimiters included. |
| 43 | + pub const CODE: u16 = 1 << 3; |
| 44 | + /// A line inside a fenced code block. |
| 45 | + pub const CODE_BLOCK: u16 = 1 << 4; |
| 46 | + /// The opening or closing line of a fence. |
| 47 | + pub const FENCE: u16 = 1 << 5; |
| 48 | + /// Text inside a block quote. |
| 49 | + pub const QUOTE: u16 = 1 << 6; |
| 50 | + /// A list bullet, number or task box. |
| 51 | + pub const LIST_MARKER: u16 = 1 << 7; |
| 52 | + pub const LINK_TEXT: u16 = 1 << 8; |
| 53 | + pub const LINK_URL: u16 = 1 << 9; |
| 54 | + /// A leading `/command` or `$skill`. |
| 55 | + pub const COMMAND: u16 = 1 << 10; |
| 56 | + /// Markdown syntax: `#`, `**`, `` ` ``, `>`, brackets, escapes. |
| 57 | + pub const SYNTAX: u16 = 1 << 11; |
| 58 | + } |
| 59 | + |
| 60 | + /// A styled run of the document: a byte range and its [`flag`]s. |
| 61 | + type Span = (Range<usize>, u16); |
| 62 | + |
| 63 | + thread_local! { |
| 64 | + /// The colorway the composer paints with. [`InputHighlighter::styles`] |
| 65 | + /// gets no `App`, so [`MarkdownInput`] records the palette here on every |
| 66 | + /// render, ahead of the editor's paint in the same frame. One colorway is |
| 67 | + /// active app-wide, so one cell serves every composer. |
| 68 | + static PALETTE: Cell<Option<&'static Palette>> = const { Cell::new(None) }; |
| 69 | + } |
| 70 | + |
| 71 | + fn active_palette() -> &'static Palette { |
| 72 | + PALETTE.with(Cell::get).unwrap_or(&metal::BONE) |
| 73 | + } |
| 74 | + |
| 75 | + /// Turns Markdown into highlight runs for an [`EditorState`]. |
| 76 | + /// |
| 77 | + /// Every edit rescans the whole document. A hand-written line scanner gets |
| 78 | + /// through a few hundred lines in microseconds, and a full rescan keeps fenced |
| 79 | + /// blocks right however an edit opens or closes them. |
| 80 | + #[derive(Default)] |
| 81 | + pub struct MarkdownHighlighter { |
| 82 | + /// Styled runs over the whole document, ordered and non-overlapping. |
| 83 | + /// Unstyled text has no entry. |
| 84 | + spans: Vec<Span>, |
| 85 | + } |
| 86 | + |
| 87 | + impl MarkdownHighlighter { |
| 88 | + pub fn new() -> Self { |
| 89 | + Self::default() |
| 90 | + } |
| 91 | + |
| 92 | + fn rescan(&mut self, text: &str) { |
| 93 | + self.spans = scan(text); |
| 94 | + } |
| 95 | + |
| 96 | + /// The highlight runs for `range`, styled with `palette`. |
| 97 | + fn styles_with(&self, range: &Range<usize>, palette: &Palette) -> Vec<(Range<usize>, HighlightStyle)> { |
| 98 | + let mut runs = Vec::new(); |
| 99 | + let mut at = range.start; |
| 100 | + let first = self.spans.partition_point(|(span, _)| span.end <= range.start); |
| 101 | + for (span, flags) in &self.spans[first..] { |
| 102 | + if span.start >= range.end { |
| 103 | + break; |
| 104 | + } |
| 105 | + let start = span.start.max(range.start); |
| 106 | + let end = span.end.min(range.end); |
| 107 | + if start > at { |
| 108 | + runs.push((at..start, HighlightStyle::default())); |
| 109 | + } |
| 110 | + runs.push((start..end, style_for(*flags, palette))); |
| 111 | + at = end; |
| 112 | + } |
| 113 | + if at < range.end { |
| 114 | + runs.push((at..range.end, HighlightStyle::default())); |
| 115 | + } |
| 116 | + runs |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + impl InputHighlighter for MarkdownHighlighter { |
| 121 | + fn language(&self) -> SharedString { |
| 122 | + "markdown".into() |
| 123 | + } |
| 124 | + |
| 125 | + fn update( |
| 126 | + &mut self, |
| 127 | + _edit: Option<InputEdit>, |
| 128 | + text: &Rope, |
| 129 | + _folding: bool, |
| 130 | + _window: &mut Window, |
| 131 | + cx: &mut Context<EditorState>, |
| 132 | + ) { |
| 133 | + PALETTE.with(|cell| cell.set(Some(metal::palette(cx)))); |
| 134 | + self.rescan(&text.to_string()); |
| 135 | + } |
| 136 | + |
| 137 | + fn update_batch( |
| 138 | + &mut self, |
| 139 | + edits: &[(InputEdit, Rope)], |
| 140 | + _folding: bool, |
| 141 | + _window: &mut Window, |
| 142 | + _cx: &mut Context<EditorState>, |
| 143 | + ) { |
| 144 | + // Only the text after the last edit matters to a full rescan. |
| 145 | + if let Some((_, text)) = edits.last() { |
| 146 | + self.rescan(&text.to_string()); |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + fn styles( |
| 151 | + &self, |
| 152 | + range: &Range<usize>, |
| 153 | + _resolver: &dyn HighlightStyleResolver, |
| 154 | + ) -> Vec<(Range<usize>, HighlightStyle)> { |
| 155 | + // The resolver is gpui-component's syntax theme, which the `Editor` |
| 156 | + // element re-installs on every render, so the colors come from the |
| 157 | + // MetalUI palette instead. |
| 158 | + self.styles_with(range, active_palette()) |
| 159 | + } |
| 160 | + |
| 161 | + fn fold_ranges(&self, _text: &Rope) -> Vec<FoldRange> { |
| 162 | + Vec::new() |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + /// The highlighter factory for an [`EditorState`]: Markdown when `on`, plain |
| 167 | + /// text otherwise. Installing a factory (even the plain one) also keeps |
| 168 | + /// gpui-component's tree-sitter factory out. |
| 169 | + pub fn highlighter_factory(on: bool) -> InputHighlighterFactory { |
| 170 | + if on { |
| 171 | + Rc::new(|_| Some(Box::new(MarkdownHighlighter::new()) as Box<dyn InputHighlighter>)) |
| 172 | + } else { |
| 173 | + Rc::new(|_| None) |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + /// Dresses a fresh [`EditorState`] as the composer: no line numbers, gutter, |
| 178 | + /// folding or indent guides, no bracket pairing or smart indent, no search |
| 179 | + /// panel or scrolling past the end, soft wrap on, and Markdown styling when |
| 180 | + /// `rich`. Chain `submit_on_enter` and `placeholder` after it as for a |
| 181 | + /// `TextareaState`, and render it with [`MarkdownInput`]. |
| 182 | + pub fn composer_state(state: EditorState, rich: bool) -> EditorState { |
| 183 | + let mut state = state |
| 184 | + .line_number(false) |
| 185 | + .folding(false) |
| 186 | + .indent_guides(false) |
| 187 | + .auto_close(false) |
| 188 | + .smart_indent(false) |
| 189 | + .soft_wrap(true) |
| 190 | + .searchable(false) |
| 191 | + .scroll_beyond_last_line(Some(0)); |
| 192 | + state.ensure_highlighter_factory(highlighter_factory(rich)); |
| 193 | + state |
| 194 | + } |
| 195 | + |
| 196 | + /// Turns Markdown styling on or off, live. |
| 197 | + pub fn enable(state: &Entity<EditorState>, on: bool, cx: &mut App) { |
| 198 | + state.update(cx, |state, cx| state.set_highlighter_factory(highlighter_factory(on), cx)); |
| 199 | + } |
| 200 | + |
| 201 | + /// Vertical padding the input puts around its text: gpui-component's |
| 202 | + /// `Size::Medium.input_py()`, the same as the plain composer's. |
| 203 | + const PADDING_Y: f32 = 8.; |
| 204 | + /// How much further right an editor starts its text than a textarea: it pads |
| 205 | + /// 6px instead of 10px, plus a 6px gap where the line numbers would end. |
| 206 | + const EDITOR_INDENT: f32 = 2.; |
| 207 | + |
| 208 | + /// Renders a [`composer_state`] editor as the plain composer would render its |
| 209 | + /// `Textarea`: borderless, in the UI font at the textarea's size and leading, |
| 210 | + /// growing with its content between `rows` (default 2 to 12). |
| 211 | + #[derive(IntoElement)] |
| 212 | + pub struct MarkdownInput { |
| 213 | + state: Entity<EditorState>, |
| 214 | + min_rows: usize, |
| 215 | + max_rows: usize, |
| 216 | + } |
| 217 | + |
| 218 | + impl MarkdownInput { |
| 219 | + pub fn new(state: &Entity<EditorState>) -> Self { |
| 220 | + Self { state: state.clone(), min_rows: 2, max_rows: 12 } |
| 221 | + } |
| 222 | + |
| 223 | + /// Grow with the content from `min` through `max` rows. |
| 224 | + pub fn rows(mut self, min: usize, max: usize) -> Self { |
| 225 | + self.min_rows = min.max(1); |
| 226 | + self.max_rows = max.max(self.min_rows); |
| 227 | + self |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + impl RenderOnce for MarkdownInput { |
| 232 | + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { |
| 233 | + PALETTE.with(|cell| cell.set(Some(metal::palette(cx)))); |
| 234 | + |
| 235 | + let (min, max) = (self.min_rows, self.max_rows); |
| 236 | + let state = self.state.read(cx); |
| 237 | + let line_height = state.line_height().unwrap_or_else(|| rems(1.25).to_pixels(window.rem_size())); |
| 238 | + let rows = content_rows(state).unwrap_or(min).clamp(min, max); |
| 239 | + |
| 240 | + // The rows above come from the last layout. Once this frame has laid |
| 241 | + // the text out, measure again and redraw if the content grew or shrank. |
| 242 | + let entity = self.state.clone(); |
| 243 | + window.on_next_frame(move |_, cx| { |
| 244 | + let measured = content_rows(entity.read(cx)).map(|now| now.clamp(min, max)); |
| 245 | + if measured.is_some_and(|now| now != rows) { |
| 246 | + cx.notify(entity.entity_id()); |
| 247 | + } |
| 248 | + }); |
| 249 | + |
| 250 | + let menu_state = self.state.clone(); |
| 251 | + Editor::new(&self.state) |
| 252 | + .appearance(false) |
| 253 | + .h(line_height * rows as f32 + px(PADDING_Y * 2.)) |
| 254 | + .context_menu(move |menu, _, cx| context_menu(&menu_state, menu, cx)) |
| 255 | + .font_family(metal::SANS) |
| 256 | + .text_size(rems(0.875)) |
| 257 | + .line_height(rems(1.25)) |
| 258 | + .ml(px(-EDITOR_INDENT)) |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + /// The plain text menu. The editor's own adds code navigation entries. |
| 263 | + fn context_menu(state: &Entity<EditorState>, menu: NativeMenu, cx: &App) -> NativeMenu { |
| 264 | + let state = state.read(cx); |
| 265 | + let editable = state.is_editable(); |
| 266 | + let selected = !state.selected_range().is_empty(); |
| 267 | + menu.menu_with_disabled("Cut", !(editable && selected), Box::new(input::Cut)) |
| 268 | + .menu_with_disabled("Copy", !selected, Box::new(input::Copy)) |
| 269 | + .menu_with_disabled("Paste", !editable, Box::new(input::Paste)) |
| 270 | + .separator() |
| 271 | + .menu("Select All", Box::new(input::SelectAll)) |
| 272 | + } |
| 273 | + |
| 274 | + /// How many rows the editor's text takes up, soft wraps included, from its |
| 275 | + /// last layout. `None` before the first layout. |
| 276 | + /// |
| 277 | + /// The laid-out lines are measured exactly; lines scrolled out of view count |
| 278 | + /// one row each, which only ever undercounts, so growing converges in a frame |
| 279 | + /// or two. |
| 280 | + fn content_rows(state: &EditorState) -> Option<usize> { |
| 281 | + let line_height = state.line_height()?; |
| 282 | + let text = state.text(); |
| 283 | + let lines = text.lines_len().max(1); |
| 284 | + let visible = state.visible_row_range()?; |
| 285 | + let visible = visible.start.min(lines)..visible.end.min(lines); |
| 286 | + if visible.is_empty() { |
| 287 | + return None; |
| 288 | + } |
| 289 | + let start = text.line_start_offset(visible.start); |
| 290 | + let end = text.line_end_offset(visible.end - 1); |
| 291 | + let top = state.range_to_bounds(&(start..start))?.top(); |
| 292 | + let bottom = state.range_to_bounds(&(end..end))?.bottom(); |
| 293 | + let measured = ((bottom - top) / line_height).round().max(1.) as usize; |
| 294 | + Some(measured + lines - visible.len()) |
| 295 | + } |
| 296 | + |
| 297 | + fn style_for(flags: u16, p: &Palette) -> HighlightStyle { |
| 298 | + let mut style = HighlightStyle::default(); |
| 299 | + if flags & (flag::HEADING | flag::STRONG) != 0 { |
| 300 | + style.font_weight = Some(FontWeight::BOLD); |
| 301 | + } |
| 302 | + if flags & flag::EMPHASIS != 0 { |
| 303 | + // Geist ships no italic face, and GPUI on Linux does not slant one, |
| 304 | + // so emphasis also gets a softer ink to stay visible. |
| 305 | + style.font_style = Some(FontStyle::Italic); |
| 306 | + style.color = Some(p.ink2); |
| 307 | + } |
| 308 | + if flags & flag::QUOTE != 0 { |
| 309 | + style.color = Some(p.ink2); |
| 310 | + } |
| 311 | + if flags & flag::LIST_MARKER != 0 { |
| 312 | + style.color = Some(p.amber_ink); |
| 313 | + } |
| 314 | + if flags & flag::COMMAND != 0 { |
| 315 | + style.color = Some(p.green_ink); |
| 316 | + style.font_weight = Some(FontWeight::MEDIUM); |
| 317 | + } |
| 318 | + if flags & flag::LINK_TEXT != 0 { |
| 319 | + style.color = Some(p.blue_ink); |
| 320 | + style.underline = |
| 321 | + Some(UnderlineStyle { thickness: px(1.), color: Some(p.blue_ink.opacity(0.5)), wavy: false }); |
| 322 | + } |
| 323 | + if flags & flag::LINK_URL != 0 { |
| 324 | + style.color = Some(p.ink3); |
| 325 | + } |
| 326 | + if flags & flag::CODE != 0 { |
| 327 | + style.color = Some(p.ink2); |
| 328 | + style.background_color = Some(p.well_top); |
| 329 | + } |
| 330 | + if flags & flag::SYNTAX != 0 { |
| 331 | + style.color = Some(p.ink3); |
| 332 | + } |
| 333 | + if flags & flag::CODE_BLOCK != 0 { |
| 334 | + style.color = Some(p.ink2); |
| 335 | + style.font_weight = None; |
| 336 | + style.font_style = None; |
| 337 | + } |
| 338 | + if flags & flag::FENCE != 0 { |
| 339 | + style.color = Some(p.ink3); |
| 340 | + } |
| 341 | + style |
| 342 | + } |
| 343 | + |
| 344 | + // --------------------------------------------------------------------------- |
| 345 | + // The scanner |
| 346 | + // --------------------------------------------------------------------------- |
| 347 | + |
| 348 | + /// An open code fence: its character and length. |
| 349 | + #[derive(Clone, Copy)] |
| 350 | + struct Fence { |
| 351 | + ch: u8, |
| 352 | + len: usize, |
| 353 | + } |
| 354 | + |
| 355 | + /// Scans `text` into styled runs: ordered, non-overlapping, on UTF-8 char |
| 356 | + /// boundaries, unstyled text left out. |
| 357 | + fn scan(text: &str) -> Vec<Span> { |
| 358 | + let mut spans = Vec::new(); |
| 359 | + let mut fence = None; |
| 360 | + let mut leading = true; |
| 361 | + let mut flags = Vec::new(); |
| 362 | + let mut literal = Vec::new(); |
| 363 | + let mut offset = 0; |
| 364 | + for raw in text.split('\n') { |
| 365 | + let line = raw.strip_suffix('\r').unwrap_or(raw); |
| 366 | + flags.clear(); |
| 367 | + flags.resize(line.len(), 0u16); |
| 368 | + literal.clear(); |
| 369 | + literal.resize(line.len(), false); |
| 370 | + // The message is sent trimmed, so its first non-blank line leads. |
| 371 | + let leads = leading && !line.trim().is_empty(); |
| 372 | + scan_line(line, &mut fence, leads, &mut flags, &mut literal); |
| 373 | + leading &= !leads; |
| 374 | + push_runs(&mut spans, offset, &flags); |
| 375 | + offset += raw.len() + 1; |
| 376 | + } |
| 377 | + spans |
| 378 | + } |
| 379 | + |
| 380 | + /// Appends the runs of equal, non-zero flags in one line. |
| 381 | + fn push_runs(spans: &mut Vec<Span>, offset: usize, flags: &[u16]) { |
| 382 | + let mut start = 0; |
| 383 | + while start < flags.len() { |
| 384 | + let value = flags[start]; |
| 385 | + let mut end = start + 1; |
| 386 | + while end < flags.len() && flags[end] == value { |
| 387 | + end += 1; |
| 388 | + } |
| 389 | + if value != 0 { |
| 390 | + spans.push((offset + start..offset + end, value)); |
| 391 | + } |
| 392 | + start = end; |
| 393 | + } |
| 394 | + } |
| 395 | + |
| 396 | + fn mark(flags: &mut [u16], range: Range<usize>, value: u16) { |
| 397 | + for slot in &mut flags[range] { |
| 398 | + *slot |= value; |
| 399 | + } |
| 400 | + } |
| 401 | + |
| 402 | + fn indent_of(bytes: &[u8], from: usize) -> usize { |
| 403 | + bytes[from..].iter().take_while(|&&b| b == b' ' || b == b'\t').count() |
| 404 | + } |
| 405 | + |
| 406 | + fn scan_line(line: &str, fence: &mut Option<Fence>, leads: bool, flags: &mut [u16], literal: &mut [bool]) { |
| 407 | + let bytes = line.as_bytes(); |
| 408 | + let len = bytes.len(); |
| 409 | + |
| 410 | + if let Some(open) = *fence { |
| 411 | + if closes_fence(bytes, open) { |
| 412 | + *fence = None; |
| 413 | + mark(flags, 0..len, flag::FENCE); |
| 414 | + } else { |
| 415 | + mark(flags, 0..len, flag::CODE_BLOCK); |
| 416 | + } |
| 417 | + return; |
| 418 | + } |
| 419 | + if let Some(open) = opens_fence(bytes) { |
| 420 | + *fence = Some(open); |
| 421 | + mark(flags, 0..len, flag::FENCE); |
| 422 | + return; |
| 423 | + } |
| 424 | + |
| 425 | + let mut pos = 0; |
| 426 | + if leads && let Some(end) = leading_command(bytes) { |
| 427 | + let start = indent_of(bytes, 0); |
| 428 | + mark(flags, start..end, flag::COMMAND); |
| 429 | + inline(line, end..len, flags, literal); |
| 430 | + return; |
| 431 | + } |
| 432 | + |
| 433 | + // Block quotes, nested or not: `>` markers, each with one optional space. |
| 434 | + loop { |
| 435 | + let at = pos + indent_of(bytes, pos); |
| 436 | + if at - pos <= 3 && bytes.get(at) == Some(&b'>') { |
| 437 | + mark(flags, at..len, flag::QUOTE); |
| 438 | + mark(flags, at..at + 1, flag::SYNTAX); |
| 439 | + pos = at + 1; |
| 440 | + if bytes.get(pos) == Some(&b' ') { |
| 441 | + pos += 1; |
| 442 | + } |
| 443 | + } else { |
| 444 | + break; |
| 445 | + } |
| 446 | + } |
| 447 | + |
| 448 | + if is_thematic_break(&bytes[pos..]) { |
| 449 | + mark(flags, pos..len, flag::SYNTAX); |
| 450 | + return; |
| 451 | + } |
| 452 | + |
| 453 | + // ATX heading: up to three spaces, one to six `#`, then a space or the end. |
| 454 | + let at = pos + indent_of(bytes, pos); |
| 455 | + let hashes = bytes[at..].iter().take_while(|&&b| b == b'#').count(); |
| 456 | + if at - pos <= 3 |
| 457 | + && (1..=6).contains(&hashes) |
| 458 | + && matches!(bytes.get(at + hashes), None | Some(b' ' | b'\t')) |
| 459 | + { |
| 460 | + mark(flags, at..len, flag::HEADING); |
| 461 | + mark(flags, at..at + hashes, flag::SYNTAX); |
| 462 | + // An optional closing run of `#` after a space. |
| 463 | + let trimmed = line.trim_end().len(); |
| 464 | + let closing = bytes[..trimmed].iter().rev().take_while(|&&b| b == b'#').count(); |
| 465 | + let mut content_end = trimmed; |
| 466 | + if closing > 0 && trimmed - closing > at + hashes && matches!(bytes[trimmed - closing - 1], b' ' | b'\t') { |
| 467 | + content_end = trimmed - closing; |
| 468 | + mark(flags, content_end..trimmed, flag::SYNTAX); |
| 469 | + } |
| 470 | + inline(line, (at + hashes).min(content_end)..content_end, flags, literal); |
| 471 | + return; |
| 472 | + } |
| 473 | + |
| 474 | + // List item: a bullet or a number, then a space or the end, then |
| 475 | + // optionally a task box. |
| 476 | + let mut content = pos; |
| 477 | + if let Some(marker_end) = list_marker(bytes, at) { |
| 478 | + mark(flags, at..marker_end, flag::LIST_MARKER); |
| 479 | + content = marker_end; |
| 480 | + let box_at = marker_end + indent_of(bytes, marker_end); |
| 481 | + if box_at > marker_end |
| 482 | + && let [b'[', b' ' | b'x' | b'X', b']', ..] = &bytes[box_at..] |
| 483 | + && matches!(bytes.get(box_at + 3), None | Some(b' ' | b'\t')) |
| 484 | + { |
| 485 | + mark(flags, box_at..box_at + 3, flag::LIST_MARKER); |
| 486 | + content = box_at + 3; |
| 487 | + } |
| 488 | + } |
| 489 | + inline(line, content..len, flags, literal); |
| 490 | + } |
| 491 | + |
| 492 | + /// An opening fence: three or more backticks or tildes, at any indent (fences |
| 493 | + /// inside list items are indented). A backtick fence's info string can't hold |
| 494 | + /// a backtick, or it's inline code. |
| 495 | + fn opens_fence(bytes: &[u8]) -> Option<Fence> { |
| 496 | + let at = indent_of(bytes, 0); |
| 497 | + let ch = *bytes.get(at)?; |
| 498 | + if ch != b'`' && ch != b'~' { |
| 499 | + return None; |
| 500 | + } |
| 501 | + let len = bytes[at..].iter().take_while(|&&b| b == ch).count(); |
| 502 | + if len < 3 || (ch == b'`' && bytes[at + len..].contains(&b'`')) { |
| 503 | + return None; |
| 504 | + } |
| 505 | + Some(Fence { ch, len }) |
| 506 | + } |
| 507 | + |
| 508 | + /// A closing fence: at least as long as the opening one, nothing after it. |
| 509 | + fn closes_fence(bytes: &[u8], open: Fence) -> bool { |
| 510 | + let at = indent_of(bytes, 0); |
| 511 | + let len = bytes[at..].iter().take_while(|&&b| b == open.ch).count(); |
| 512 | + len >= open.len && bytes[at + len..].iter().all(|b| b.is_ascii_whitespace()) |
| 513 | + } |
| 514 | + |
| 515 | + /// `---`, `***` or `___`, optionally spaced out. |
| 516 | + fn is_thematic_break(bytes: &[u8]) -> bool { |
| 517 | + let mut ch = None; |
| 518 | + let mut count = 0; |
| 519 | + for &b in bytes { |
| 520 | + match b { |
| 521 | + b' ' | b'\t' => {} |
| 522 | + b'-' | b'*' | b'_' if ch.is_none_or(|c| c == b) => { |
| 523 | + ch = Some(b); |
| 524 | + count += 1; |
| 525 | + } |
| 526 | + _ => return false, |
| 527 | + } |
| 528 | + } |
| 529 | + count >= 3 |
| 530 | + } |
| 531 | + |
| 532 | + /// The end of a list marker starting at `at`: `-`, `*` or `+`, or up to nine |
| 533 | + /// digits and `.` or `)`, followed by a space or the end of the line. |
| 534 | + fn list_marker(bytes: &[u8], at: usize) -> Option<usize> { |
| 535 | + let end = match *bytes.get(at)? { |
| 536 | + b'-' | b'*' | b'+' => at + 1, |
| 537 | + b'0'..=b'9' => { |
| 538 | + let digits = bytes[at..].iter().take_while(|b| b.is_ascii_digit()).count(); |
| 539 | + if digits > 9 || !matches!(bytes.get(at + digits), Some(b'.' | b')')) { |
| 540 | + return None; |
| 541 | + } |
| 542 | + at + digits + 1 |
| 543 | + } |
| 544 | + _ => return None, |
| 545 | + }; |
| 546 | + matches!(bytes.get(end), None | Some(b' ' | b'\t')).then_some(end) |
| 547 | + } |
| 548 | + |
| 549 | + /// The end of a `/command` or `$skill` at the start of the line. |
| 550 | + fn leading_command(bytes: &[u8]) -> Option<usize> { |
| 551 | + let at = indent_of(bytes, 0); |
| 552 | + let sigil = *bytes.get(at)?; |
| 553 | + if sigil != b'/' && sigil != b'$' { |
| 554 | + return None; |
| 555 | + } |
| 556 | + let name = bytes[at + 1..] |
| 557 | + .iter() |
| 558 | + .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.')) |
| 559 | + .count(); |
| 560 | + let first = *bytes.get(at + 1)?; |
| 561 | + let starts_well = if sigil == b'$' { first.is_ascii_alphabetic() } else { first.is_ascii_alphanumeric() }; |
| 562 | + let end = at + 1 + name; |
| 563 | + (name > 0 && starts_well && matches!(bytes.get(end), None | Some(b' ' | b'\t'))).then_some(end) |
| 564 | + } |
| 565 | + |
| 566 | + /// Marks inline Markdown in `range` of `line`: escapes, code spans, links, |
| 567 | + /// bare URLs and emphasis, in that order of precedence. `literal` collects |
| 568 | + /// bytes that later passes must not read as syntax. |
| 569 | + fn inline(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) { |
| 570 | + let bytes = line.as_bytes(); |
| 571 | + let Range { start, end } = range; |
| 572 | + |
| 573 | + // Escapes and code spans, left to right as CommonMark reads them. |
| 574 | + let mut i = start; |
| 575 | + while i < end { |
| 576 | + match bytes[i] { |
| 577 | + b'\\' if i + 1 < end && bytes[i + 1].is_ascii_punctuation() => { |
| 578 | + mark(flags, i..i + 1, flag::SYNTAX); |
| 579 | + literal[i] = true; |
| 580 | + literal[i + 1] = true; |
| 581 | + i += 2; |
| 582 | + } |
| 583 | + b'`' => { |
| 584 | + let run = run_len(bytes, i, end, b'`'); |
| 585 | + if let Some(close) = find_backtick_run(bytes, i + run, end, run) { |
| 586 | + mark(flags, i..close + run, flag::CODE); |
| 587 | + mark(flags, i..i + run, flag::SYNTAX); |
| 588 | + mark(flags, close..close + run, flag::SYNTAX); |
| 589 | + literal[i..close + run].fill(true); |
| 590 | + i = close + run; |
| 591 | + } else { |
| 592 | + literal[i..i + run].fill(true); |
| 593 | + i += run; |
| 594 | + } |
| 595 | + } |
| 596 | + _ => i += 1, |
| 597 | + } |
| 598 | + } |
| 599 | + |
| 600 | + links(line, start..end, flags, literal); |
| 601 | + bare_urls(line, start..end, flags, literal); |
| 602 | + emphasis(line, start..end, flags, literal); |
| 603 | + } |
| 604 | + |
| 605 | + fn run_len(bytes: &[u8], at: usize, end: usize, ch: u8) -> usize { |
| 606 | + bytes[at..end].iter().take_while(|&&b| b == ch).count() |
| 607 | + } |
| 608 | + |
| 609 | + /// The start of the next run of exactly `len` backticks in `from..end`. |
| 610 | + fn find_backtick_run(bytes: &[u8], from: usize, end: usize, len: usize) -> Option<usize> { |
| 611 | + let mut i = from; |
| 612 | + while i < end { |
| 613 | + if bytes[i] == b'`' { |
| 614 | + let run = run_len(bytes, i, end, b'`'); |
| 615 | + if run == len { |
| 616 | + return Some(i); |
| 617 | + } |
| 618 | + i += run; |
| 619 | + } else { |
| 620 | + i += 1; |
| 621 | + } |
| 622 | + } |
| 623 | + None |
| 624 | + } |
| 625 | + |
| 626 | + /// `[text](url)` and ``. |
| 627 | + fn links(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) { |
| 628 | + let bytes = line.as_bytes(); |
| 629 | + let Range { start, end } = range; |
| 630 | + let mut i = start; |
| 631 | + while i < end { |
| 632 | + if bytes[i] != b'[' || literal[i] { |
| 633 | + i += 1; |
| 634 | + continue; |
| 635 | + } |
| 636 | + let Some(close) = matching(bytes, literal, i, end, b'[', b']') else { |
| 637 | + i += 1; |
| 638 | + continue; |
| 639 | + }; |
| 640 | + let Some(paren_close) = (bytes.get(close + 1) == Some(&b'(')) |
| 641 | + .then(|| matching(bytes, literal, close + 1, end, b'(', b')')) |
| 642 | + .flatten() |
| 643 | + else { |
| 644 | + i += 1; |
| 645 | + continue; |
| 646 | + }; |
| 647 | + let open = if i > start && bytes[i - 1] == b'!' && !literal[i - 1] { i - 1 } else { i }; |
| 648 | + mark(flags, i + 1..close, flag::LINK_TEXT); |
| 649 | + mark(flags, close + 2..paren_close, flag::LINK_URL); |
| 650 | + mark(flags, open..i + 1, flag::SYNTAX); |
| 651 | + mark(flags, close..close + 2, flag::SYNTAX); |
| 652 | + mark(flags, paren_close..paren_close + 1, flag::SYNTAX); |
| 653 | + for at in [i, close, close + 1, paren_close] { |
| 654 | + literal[at] = true; |
| 655 | + } |
| 656 | + literal[open] = true; |
| 657 | + literal[close + 2..paren_close].fill(true); |
| 658 | + i = paren_close + 1; |
| 659 | + } |
| 660 | + } |
| 661 | + |
| 662 | + /// The index of the bracket closing the one at `open`, skipping literal bytes. |
| 663 | + fn matching(bytes: &[u8], literal: &[bool], open: usize, end: usize, left: u8, right: u8) -> Option<usize> { |
| 664 | + let mut depth = 0usize; |
| 665 | + for i in open..end { |
| 666 | + if literal[i] && i != open { |
| 667 | + continue; |
| 668 | + } |
| 669 | + if bytes[i] == left { |
| 670 | + depth += 1; |
| 671 | + } else if bytes[i] == right { |
| 672 | + depth -= 1; |
| 673 | + if depth == 0 { |
| 674 | + return Some(i); |
| 675 | + } |
| 676 | + } |
| 677 | + } |
| 678 | + None |
| 679 | + } |
| 680 | + |
| 681 | + /// `http://` and `https://` URLs written out in the text. |
| 682 | + fn bare_urls(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) { |
| 683 | + let bytes = line.as_bytes(); |
| 684 | + let mut search = range.start; |
| 685 | + while let Some(found) = line[search..range.end].find("http") { |
| 686 | + let at = search + found; |
| 687 | + let rest = &line[at..range.end]; |
| 688 | + let scheme = if rest.starts_with("https://") { |
| 689 | + 8 |
| 690 | + } else if rest.starts_with("http://") { |
| 691 | + 7 |
| 692 | + } else { |
| 693 | + search = at + 4; |
| 694 | + continue; |
| 695 | + }; |
| 696 | + let word_start = at == range.start || matches!(bytes[at - 1], b' ' | b'\t' | b'(' | b'<' | b'"' | b'\''); |
| 697 | + if !word_start || literal[at] { |
| 698 | + search = at + scheme; |
| 699 | + continue; |
| 700 | + } |
| 701 | + let mut end = at + rest.find(|c: char| c.is_whitespace() || c == '<' || c == '>').unwrap_or(rest.len()); |
| 702 | + while end > at + scheme && matches!(bytes[end - 1], b'.' | b',' | b':' | b';' | b'!' | b'?' | b')' | b'\'' | b'"') { |
| 703 | + end -= 1; |
| 704 | + } |
| 705 | + if end > at + scheme { |
| 706 | + mark(flags, at..end, flag::LINK_TEXT); |
| 707 | + literal[at..end].fill(true); |
| 708 | + } |
| 709 | + search = end.max(at + scheme); |
| 710 | + } |
| 711 | + } |
| 712 | + |
| 713 | + /// A run of `*` or `_` that may open or close emphasis. |
| 714 | + struct Delimiter { |
| 715 | + ch: u8, |
| 716 | + /// Where the run's unused delimiters start. |
| 717 | + start: usize, |
| 718 | + /// How many are left unused. |
| 719 | + len: usize, |
| 720 | + original_len: usize, |
| 721 | + can_open: bool, |
| 722 | + can_close: bool, |
| 723 | + } |
| 724 | + |
| 725 | + fn is_punctuation(c: char) -> bool { |
| 726 | + c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) |
| 727 | + } |
| 728 | + |
| 729 | + /// `*emphasis*`, `_emphasis_`, `**strong**` and `__strong__`, matched with |
| 730 | + /// CommonMark's delimiter-run rules (flanking, intraword `_`, rule of three). |
| 731 | + fn emphasis(line: &str, range: Range<usize>, flags: &mut [u16], literal: &[bool]) { |
| 732 | + let bytes = line.as_bytes(); |
| 733 | + let mut runs: Vec<Delimiter> = Vec::new(); |
| 734 | + let mut i = range.start; |
| 735 | + while i < range.end { |
| 736 | + let ch = bytes[i]; |
| 737 | + if (ch != b'*' && ch != b'_') || literal[i] { |
| 738 | + i += 1; |
| 739 | + continue; |
| 740 | + } |
| 741 | + let mut len = 1; |
| 742 | + while i + len < range.end && bytes[i + len] == ch && !literal[i + len] { |
| 743 | + len += 1; |
| 744 | + } |
| 745 | + let before = line[..i].chars().next_back().unwrap_or(' '); |
| 746 | + let after = line[i + len..].chars().next().unwrap_or(' '); |
| 747 | + let left = !after.is_whitespace() |
| 748 | + && (!is_punctuation(after) || before.is_whitespace() || is_punctuation(before)); |
| 749 | + let right = !before.is_whitespace() |
| 750 | + && (!is_punctuation(before) || after.is_whitespace() || is_punctuation(after)); |
| 751 | + let (can_open, can_close) = if ch == b'*' { |
| 752 | + (left, right) |
| 753 | + } else { |
| 754 | + (left && (!right || is_punctuation(before)), right && (!left || is_punctuation(after))) |
| 755 | + }; |
| 756 | + if can_open || can_close { |
| 757 | + runs.push(Delimiter { ch, start: i, len, original_len: len, can_open, can_close }); |
| 758 | + } |
| 759 | + i += len; |
| 760 | + } |
| 761 | + |
| 762 | + for closer in 0..runs.len() { |
| 763 | + if !runs[closer].can_close { |
| 764 | + continue; |
| 765 | + } |
| 766 | + while runs[closer].len > 0 { |
| 767 | + let opener = (0..closer).rev().find(|&o| { |
| 768 | + let (o, c) = (&runs[o], &runs[closer]); |
| 769 | + let odd_match = (o.can_close || c.can_open) |
| 770 | + && (o.original_len + c.original_len) % 3 == 0 |
| 771 | + && !(o.original_len % 3 == 0 && c.original_len % 3 == 0); |
| 772 | + o.ch == c.ch && o.can_open && o.len > 0 && !odd_match |
| 773 | + }); |
| 774 | + let Some(opener) = opener else { break }; |
| 775 | + let used = if runs[opener].len >= 2 && runs[closer].len >= 2 { 2 } else { 1 }; |
| 776 | + let open_end = runs[opener].start + runs[opener].len; |
| 777 | + let close_start = runs[closer].start; |
| 778 | + mark(flags, open_end - used..open_end, flag::SYNTAX); |
| 779 | + mark(flags, close_start..close_start + used, flag::SYNTAX); |
| 780 | + mark(flags, open_end..close_start, if used == 2 { flag::STRONG } else { flag::EMPHASIS }); |
| 781 | + runs[opener].len -= used; |
| 782 | + runs[closer].start += used; |
| 783 | + runs[closer].len -= used; |
| 784 | + for between in &mut runs[opener + 1..closer] { |
| 785 | + between.len = 0; |
| 786 | + } |
| 787 | + } |
| 788 | + } |
| 789 | + } |
| 790 | + |
| 791 | + #[cfg(test)] |
| 792 | + mod tests { |
| 793 | + // Not `super::*`: GPUI's glob exports its own `test` attribute. |
| 794 | + use std::ops::Range; |
| 795 | + |
| 796 | + use gpui_kit::{FontWeight, HighlightStyle}; |
| 797 | + |
| 798 | + use super::{MarkdownHighlighter, flag, metal, scan}; |
| 799 | + |
| 800 | + /// The flags of the bytes covered by `needle`'s first occurrence, which |
| 801 | + /// must all agree. |
| 802 | + fn flags_of(text: &str, needle: &str) -> u16 { |
| 803 | + let at = text.find(needle).unwrap_or_else(|| panic!("{needle:?} not in {text:?}")); |
| 804 | + flags_at(text, at..at + needle.len()) |
| 805 | + } |
| 806 | + |
| 807 | + fn flags_at(text: &str, range: Range<usize>) -> u16 { |
| 808 | + let spans = scan(text); |
| 809 | + let value = |offset: usize| { |
| 810 | + spans.iter().find(|(span, _)| span.contains(&offset)).map_or(0, |(_, flags)| *flags) |
| 811 | + }; |
| 812 | + let first = value(range.start); |
| 813 | + for offset in range.clone() { |
| 814 | + assert_eq!(value(offset), first, "mixed flags in {:?} of {text:?}", &text[range.clone()]); |
| 815 | + } |
| 816 | + first |
| 817 | + } |
| 818 | + |
| 819 | + fn assert_well_formed(text: &str) { |
| 820 | + let spans = scan(text); |
| 821 | + let mut last = 0; |
| 822 | + for (span, flags) in &spans { |
| 823 | + assert!(span.start >= last && span.start < span.end, "{spans:?}"); |
| 824 | + assert!(*flags != 0); |
| 825 | + assert!(text.is_char_boundary(span.start) && text.is_char_boundary(span.end), "{span:?}"); |
| 826 | + assert!(span.end <= text.len()); |
| 827 | + last = span.end; |
| 828 | + } |
| 829 | + } |
| 830 | + |
| 831 | + /// `styles` must cover any requested range exactly, in order. |
| 832 | + fn assert_covers(text: &str, range: Range<usize>) { |
| 833 | + let mut highlighter = MarkdownHighlighter::new(); |
| 834 | + highlighter.rescan(text); |
| 835 | + let runs = highlighter.styles_with(&range, &metal::BONE); |
| 836 | + let mut at = range.start; |
| 837 | + for (run, _) in &runs { |
| 838 | + assert_eq!(run.start, at, "gap or overlap in {runs:?}"); |
| 839 | + assert!(run.end > run.start, "empty run in {runs:?}"); |
| 840 | + assert!(text.is_char_boundary(run.start) && text.is_char_boundary(run.end)); |
| 841 | + at = run.end; |
| 842 | + } |
| 843 | + assert_eq!(at, range.end, "runs end early: {runs:?}"); |
| 844 | + } |
| 845 | + |
| 846 | + #[test] |
| 847 | + fn headings() { |
| 848 | + let text = "# Title\n###### Six\n####### seven\n#hashtag\n## Closed ##"; |
| 849 | + assert_eq!(flags_of(text, "#"), flag::HEADING | flag::SYNTAX); |
| 850 | + assert_eq!(flags_of(text, "Title"), flag::HEADING); |
| 851 | + assert_eq!(flags_of(text, "Six"), flag::HEADING); |
| 852 | + assert_eq!(flags_of(text, "seven"), 0); |
| 853 | + assert_eq!(flags_of(text, "#hashtag"), 0); |
| 854 | + assert_eq!(flags_of(text, "Closed"), flag::HEADING); |
| 855 | + let closing = text.rfind("##").unwrap(); |
| 856 | + assert_eq!(flags_at(text, closing..closing + 2), flag::HEADING | flag::SYNTAX); |
| 857 | + assert_eq!(flags_of("# **Bold** head", "Bold"), flag::HEADING | flag::STRONG); |
| 858 | + } |
| 859 | + |
| 860 | + #[test] |
| 861 | + fn emphasis_and_strong() { |
| 862 | + let text = "a *one* b **two** c _three_ d __four__ e ***five***"; |
| 863 | + assert_eq!(flags_of(text, "one"), flag::EMPHASIS); |
| 864 | + assert_eq!(flags_of(text, "two"), flag::STRONG); |
| 865 | + assert_eq!(flags_of(text, "three"), flag::EMPHASIS); |
| 866 | + assert_eq!(flags_of(text, "four"), flag::STRONG); |
| 867 | + assert_eq!(flags_of(text, "five"), flag::STRONG | flag::EMPHASIS); |
| 868 | + assert_eq!(flags_of(text, "**"), flag::SYNTAX); |
| 869 | + assert_eq!(flags_of(text, " b "), 0); |
| 870 | + } |
| 871 | + |
| 872 | + #[test] |
| 873 | + fn emphasis_that_is_not() { |
| 874 | + // Intraword underscores, spaced-out stars and unclosed runs stay text. |
| 875 | + for text in ["snake_case_name", "2 * 3 * 4", "a **b", "* not a list*either", "x__y__z"] { |
| 876 | + let spans = scan(text); |
| 877 | + assert!(spans.iter().all(|(_, flags)| flags & (flag::EMPHASIS | flag::STRONG) == 0), "{text:?}: {spans:?}"); |
| 878 | + } |
| 879 | + // Stars inside a word still do. |
| 880 | + assert_eq!(flags_of("un*frigging*believable", "frigging"), flag::EMPHASIS); |
| 881 | + } |
| 882 | + |
| 883 | + #[test] |
| 884 | + fn nested_emphasis() { |
| 885 | + let text = "**bold *both* bold**"; |
| 886 | + assert_eq!(flags_of(text, "both"), flag::STRONG | flag::EMPHASIS); |
| 887 | + assert_eq!(flags_of(text, "bold "), flag::STRONG); |
| 888 | + } |
| 889 | + |
| 890 | + #[test] |
| 891 | + fn inline_code() { |
| 892 | + let text = "run `cargo *test*` now and ``a ` b``"; |
| 893 | + assert_eq!(flags_of(text, "cargo *test*"), flag::CODE); |
| 894 | + assert_eq!(flags_of(text, "`"), flag::CODE | flag::SYNTAX); |
| 895 | + assert_eq!(flags_of(text, "a ` b"), flag::CODE); |
| 896 | + assert_eq!(flags_of(text, " now "), 0); |
| 897 | + // An unmatched backtick is just a backtick. |
| 898 | + assert_eq!(scan("a ` b"), vec![]); |
| 899 | + } |
| 900 | + |
| 901 | + #[test] |
| 902 | + fn escapes() { |
| 903 | + let text = r"\*not emphasis\* and \`not code\`"; |
| 904 | + assert_eq!(flags_of(text, "not emphasis"), 0); |
| 905 | + assert_eq!(flags_of(text, "not code"), 0); |
| 906 | + assert_eq!(flags_at(text, 0..1), flag::SYNTAX); |
| 907 | + } |
| 908 | + |
| 909 | + /// The flags of line `n`, which must agree across it. |
| 910 | + fn line_flags(text: &str, n: usize) -> u16 { |
| 911 | + let start: usize = text.split('\n').take(n).map(|line| line.len() + 1).sum(); |
| 912 | + let line = text.split('\n').nth(n).unwrap(); |
| 913 | + flags_at(text, start..start + line.len()) |
| 914 | + } |
| 915 | + |
| 916 | + #[test] |
| 917 | + fn fences_span_lines() { |
| 918 | + let text = "before\n```rust\nlet x = *y*;\n# not a heading\n```\nafter *em*"; |
| 919 | + assert_eq!(line_flags(text, 0), 0); |
| 920 | + assert_eq!(line_flags(text, 1), flag::FENCE); |
| 921 | + assert_eq!(line_flags(text, 2), flag::CODE_BLOCK); |
| 922 | + assert_eq!(line_flags(text, 3), flag::CODE_BLOCK); |
| 923 | + assert_eq!(line_flags(text, 4), flag::FENCE); |
| 924 | + assert_eq!(flags_of(text, "em"), flag::EMPHASIS); |
| 925 | + } |
| 926 | + |
| 927 | + #[test] |
| 928 | + fn fences_need_a_matching_close() { |
| 929 | + // A shorter run or another character doesn't close; an unclosed fence |
| 930 | + // runs to the end, as it does on GitHub. |
| 931 | + let text = "~~~~\n~~~\n```\nstill code\n~~~~~\nout"; |
| 932 | + assert_eq!(line_flags(text, 0), flag::FENCE); |
| 933 | + assert_eq!(line_flags(text, 1), flag::CODE_BLOCK); |
| 934 | + assert_eq!(line_flags(text, 2), flag::CODE_BLOCK); |
| 935 | + assert_eq!(line_flags(text, 3), flag::CODE_BLOCK); |
| 936 | + assert_eq!(line_flags(text, 4), flag::FENCE); |
| 937 | + assert_eq!(line_flags(text, 5), 0); |
| 938 | + assert_eq!(line_flags("```\nopen\nto the end", 2), flag::CODE_BLOCK); |
| 939 | + // Backticks in the info string make it inline code, not a fence. |
| 940 | + assert_eq!(flags_of("```a``` b", "b"), 0); |
| 941 | + assert_eq!(flags_of("```a``` b", "a"), flag::CODE); |
| 942 | + // Indented fences count, as inside list items. |
| 943 | + assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "code"), flag::CODE_BLOCK); |
| 944 | + assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "out"), 0); |
| 945 | + } |
| 946 | + |
| 947 | + #[test] |
| 948 | + fn quotes_lists_and_rules() { |
| 949 | + let text = "> quoted *em*"; |
| 950 | + assert_eq!(flags_of(text, ">"), flag::QUOTE | flag::SYNTAX); |
| 951 | + assert_eq!(flags_of(text, "quoted"), flag::QUOTE); |
| 952 | + assert_eq!(flags_of(text, "em"), flag::QUOTE | flag::EMPHASIS); |
| 953 | + for (text, marker) in |
| 954 | + [("- item", "-"), ("* star", "*"), ("+ plus", "+"), ("12. twelve", "12."), ("3) three", "3)"), (" - nested", "-")] |
| 955 | + { |
| 956 | + assert_eq!(flags_of(text, marker), flag::LIST_MARKER, "{text:?}"); |
| 957 | + assert_eq!(flags_at(text, text.len() - 3..text.len()), 0, "{text:?}"); |
| 958 | + } |
| 959 | + assert_eq!(flags_of("- [x] done", "[x]"), flag::LIST_MARKER); |
| 960 | + assert_eq!(flags_of("- [x] done", "done"), 0); |
| 961 | + assert_eq!(flags_of("---", "---"), flag::SYNTAX); |
| 962 | + assert_eq!(flags_of("* * *", "* * *"), flag::SYNTAX); |
| 963 | + assert_eq!(flags_of("-not", "-not"), 0); |
| 964 | + assert_eq!(flags_of("1.5 times", "1.5"), 0); |
| 965 | + assert_eq!(flags_of("> - nested", "-"), flag::QUOTE | flag::LIST_MARKER); |
| 966 | + } |
| 967 | + |
| 968 | + #[test] |
| 969 | + fn links_and_urls() { |
| 970 | + let text = "see [the docs](https://x.dev/a_b_c) and  or https://t3.chat/x."; |
| 971 | + assert_eq!(flags_of(text, "the docs"), flag::LINK_TEXT); |
| 972 | + assert_eq!(flags_of(text, "https://x.dev/a_b_c"), flag::LINK_URL); |
| 973 | + assert_eq!(flags_of(text, "]("), flag::SYNTAX); |
| 974 | + assert_eq!(flags_of(text, "![") , flag::SYNTAX); |
| 975 | + assert_eq!(flags_of(text, "https://t3.chat/x"), flag::LINK_TEXT); |
| 976 | + assert_eq!(flags_at(text, text.len() - 1..text.len()), 0); |
| 977 | + assert_eq!(flags_of("[not a link] (x)", "not a link"), 0); |
| 978 | + } |
| 979 | + |
| 980 | + #[test] |
| 981 | + fn leading_commands() { |
| 982 | + assert_eq!(flags_of("/review this *now*", "/review"), flag::COMMAND); |
| 983 | + assert_eq!(flags_of("/review this *now*", "now"), flag::EMPHASIS); |
| 984 | + assert_eq!(flags_of("\n $deploy-prod please", "$deploy-prod"), flag::COMMAND); |
| 985 | + assert_eq!(flags_of("/plugin:cmd", "/plugin:cmd"), flag::COMMAND); |
| 986 | + // Not leading, a path, or a price: plain text. |
| 987 | + assert_eq!(flags_of("hi\n/review", "/review"), 0); |
| 988 | + assert_eq!(flags_of("/home/joni is mine", "/home"), 0); |
| 989 | + assert_eq!(flags_of("$5 please", "$5"), 0); |
| 990 | + } |
| 991 | + |
| 992 | + #[test] |
| 993 | + fn unicode_text() { |
| 994 | + let text = "# Grüße 👋\n**fett** und *kursiv* — `naïve`\n> ünïcödé [lïnk](ü)\n日本語の*強調*です"; |
| 995 | + assert_well_formed(text); |
| 996 | + assert_eq!(flags_of(text, "Grüße 👋"), flag::HEADING); |
| 997 | + assert_eq!(flags_of(text, "fett"), flag::STRONG); |
| 998 | + assert_eq!(flags_of(text, "kursiv"), flag::EMPHASIS); |
| 999 | + assert_eq!(flags_of(text, "naïve"), flag::CODE); |
| 1000 | + assert_eq!(flags_of(text, "lïnk"), flag::QUOTE | flag::LINK_TEXT); |
| 1001 | + assert_eq!(flags_of(text, "強調"), flag::EMPHASIS); |
| 1002 | + for end in (0..=text.len()).filter(|&i| text.is_char_boundary(i)) { |
| 1003 | + assert_covers(text, 0..end); |
| 1004 | + assert_covers(text, end..text.len()); |
| 1005 | + } |
| 1006 | + } |
| 1007 | + |
| 1008 | + #[test] |
| 1009 | + fn styles_cover_every_line_range() { |
| 1010 | + let text = "# H\n\n- a **b** `c`\r\n```\nx\n```\n> q [l](u)\n__u__ *e* ~ end"; |
| 1011 | + assert_well_formed(text); |
| 1012 | + let mut starts = vec![0]; |
| 1013 | + starts.extend(text.match_indices('\n').map(|(i, _)| i + 1)); |
| 1014 | + starts.push(text.len()); |
| 1015 | + for (i, &start) in starts.iter().enumerate() { |
| 1016 | + for &end in &starts[i..] { |
| 1017 | + assert_covers(text, start..end); |
| 1018 | + } |
| 1019 | + } |
| 1020 | + assert_covers("", 0..0); |
| 1021 | + assert!(MarkdownHighlighter::new().styles_with(&(0..0), &metal::BONE).is_empty()); |
| 1022 | + } |
| 1023 | + |
| 1024 | + #[test] |
| 1025 | + fn crlf_lines() { |
| 1026 | + let text = "**a**\r\n# b\r\n"; |
| 1027 | + assert_well_formed(text); |
| 1028 | + assert_eq!(flags_of(text, "a"), flag::STRONG); |
| 1029 | + assert_eq!(flags_of(text, "b"), flag::HEADING); |
| 1030 | + assert_eq!(flags_of(text, "\r"), 0); |
| 1031 | + } |
| 1032 | + |
| 1033 | + #[test] |
| 1034 | + fn styles_follow_flags() { |
| 1035 | + let mut highlighter = MarkdownHighlighter::new(); |
| 1036 | + let text = "**b** `c`"; |
| 1037 | + highlighter.rescan(text); |
| 1038 | + let runs = highlighter.styles_with(&(0..text.len()), &metal::GRAPHITE); |
| 1039 | + let style_at = |offset: usize| runs.iter().find(|(run, _)| run.contains(&offset)).unwrap().1; |
| 1040 | + assert_eq!(style_at(2).font_weight, Some(FontWeight::BOLD)); |
| 1041 | + assert_eq!(style_at(0).color, Some(metal::GRAPHITE.ink3)); |
| 1042 | + assert_eq!(style_at(7).background_color, Some(metal::GRAPHITE.well_top)); |
| 1043 | + assert_eq!(style_at(5), HighlightStyle::default()); |
| 1044 | + } |
| 1045 | + |
| 1046 | + #[test] |
| 1047 | + fn a_few_hundred_lines_scan_fast() { |
| 1048 | + let block = "# Heading\nSome *text* with **bold**, `code` and [a link](https://x.y).\n- item one\n```\nfn main() {}\n```\n> quote\n"; |
| 1049 | + let text = block.repeat(100); |
| 1050 | + let started = std::time::Instant::now(); |
| 1051 | + for _ in 0..10 { |
| 1052 | + assert!(!scan(&text).is_empty()); |
| 1053 | + } |
| 1054 | + assert!(started.elapsed() < std::time::Duration::from_millis(200), "{:?}", started.elapsed()); |
| 1055 | + } |
| 1056 | + } |