//! The rich text composer: Markdown styled live as you type. //! //! The text stays plain Markdown (that is what gets sent); only its styling //! changes. Headings and `**strong**` turn bold, `*emphasis*` italic, code //! sits on a faint well, quotes and fenced blocks are muted, list markers, //! links and a leading `/command` or `$skill` are tinted, and the syntax //! characters themselves fade back. //! //! gpui-base only paints highlighter runs in its code-editor layout, so the //! rich composer is an [`EditorState`] dressed as the plain composer: no //! gutter, folding, indent guides, auto-closing or smart indent, soft wrap on, //! and [`MarkdownInput`] grows it from two to twelve rows the way //! `TextareaState::auto_grow` does. [`MarkdownHighlighter`] supplies the runs. //! //! ```ignore //! let composer = cx.new(|cx| { //! markdown_input::composer_state(EditorState::new(window, cx), rich) //! .submit_on_enter(true) //! .placeholder("Ask for changes") //! }); //! // render: MarkdownInput::new(&composer) //! // toggle: markdown_input::enable(&composer, on, cx) //! ``` use std::{cell::Cell, ops::Range, rc::Rc}; use gpui_kit::component::input::{ self, Editor, EditorState, FoldRange, HighlightStyleResolver, InputEdit, InputHighlighter, InputHighlighterFactory, Rope, RopeExt as _, }; use gpui_kit::component::native_menu::NativeMenu; use gpui_kit::*; use crate::ui::metal::{self, Palette}; /// What a byte of Markdown is, as bit flags; a byte can be several at once /// (bold inside a heading, a delimiter of inline code). mod flag { pub const HEADING: u16 = 1 << 0; pub const STRONG: u16 = 1 << 1; pub const EMPHASIS: u16 = 1 << 2; /// Inline code, delimiters included. pub const CODE: u16 = 1 << 3; /// A line inside a fenced code block. pub const CODE_BLOCK: u16 = 1 << 4; /// The opening or closing line of a fence. pub const FENCE: u16 = 1 << 5; /// Text inside a block quote. pub const QUOTE: u16 = 1 << 6; /// A list bullet, number or task box. pub const LIST_MARKER: u16 = 1 << 7; pub const LINK_TEXT: u16 = 1 << 8; pub const LINK_URL: u16 = 1 << 9; /// A leading `/command` or `$skill`. pub const COMMAND: u16 = 1 << 10; /// Markdown syntax: `#`, `**`, `` ` ``, `>`, brackets, escapes. pub const SYNTAX: u16 = 1 << 11; } /// A styled run of the document: a byte range and its [`flag`]s. type Span = (Range, u16); thread_local! { /// The theme the composer paints with. [`InputHighlighter::styles`] /// gets no `App`, so [`MarkdownInput`] records the palette here on every /// render, ahead of the editor's paint in the same frame. One theme is /// active app-wide, so one cell serves every composer. static PALETTE: Cell> = const { Cell::new(None) }; } fn active_palette() -> &'static Palette { PALETTE.with(Cell::get).unwrap_or(&metal::BONE) } /// Turns Markdown into highlight runs for an [`EditorState`]. /// /// Every edit rescans the whole document. A hand-written line scanner gets /// through a few hundred lines in microseconds, and a full rescan keeps fenced /// blocks right however an edit opens or closes them. #[derive(Default)] pub struct MarkdownHighlighter { /// Styled runs over the whole document, ordered and non-overlapping. /// Unstyled text has no entry. spans: Vec, } impl MarkdownHighlighter { pub fn new() -> Self { Self::default() } fn rescan(&mut self, text: &str) { self.spans = scan(text); } /// The highlight runs for `range`, styled with `palette`. fn styles_with(&self, range: &Range, palette: &Palette) -> Vec<(Range, HighlightStyle)> { let mut runs = Vec::new(); let mut at = range.start; let first = self.spans.partition_point(|(span, _)| span.end <= range.start); for (span, flags) in &self.spans[first..] { if span.start >= range.end { break; } let start = span.start.max(range.start); let end = span.end.min(range.end); if start > at { runs.push((at..start, HighlightStyle::default())); } runs.push((start..end, style_for(*flags, palette))); at = end; } if at < range.end { runs.push((at..range.end, HighlightStyle::default())); } runs } } impl InputHighlighter for MarkdownHighlighter { fn language(&self) -> SharedString { "markdown".into() } fn update( &mut self, _edit: Option, text: &Rope, _folding: bool, _window: &mut Window, cx: &mut Context, ) { PALETTE.with(|cell| cell.set(Some(metal::palette(cx)))); self.rescan(&text.to_string()); } fn update_batch( &mut self, edits: &[(InputEdit, Rope)], _folding: bool, _window: &mut Window, _cx: &mut Context, ) { // Only the text after the last edit matters to a full rescan. if let Some((_, text)) = edits.last() { self.rescan(&text.to_string()); } } fn styles( &self, range: &Range, _resolver: &dyn HighlightStyleResolver, ) -> Vec<(Range, HighlightStyle)> { // The resolver is gpui-component's syntax theme, which the `Editor` // element re-installs on every render, so the colors come from the // MetalUI palette instead. self.styles_with(range, active_palette()) } fn fold_ranges(&self, _text: &Rope) -> Vec { Vec::new() } } /// The highlighter factory for an [`EditorState`]: Markdown when `on`, plain /// text otherwise. Installing a factory (even the plain one) also keeps /// gpui-component's tree-sitter factory out. pub fn highlighter_factory(on: bool) -> InputHighlighterFactory { if on { Rc::new(|_| Some(Box::new(MarkdownHighlighter::new()) as Box)) } else { Rc::new(|_| None) } } /// Dresses a fresh [`EditorState`] as the composer: no line numbers, gutter, /// folding or indent guides, no bracket pairing or smart indent, no search /// panel or scrolling past the end, soft wrap on, and Markdown styling when /// `rich`. Chain `submit_on_enter` and `placeholder` after it as for a /// `TextareaState`, and render it with [`MarkdownInput`]. pub fn composer_state(state: EditorState, rich: bool) -> EditorState { let mut state = state .line_number(false) .folding(false) .indent_guides(false) .auto_close(false) .smart_indent(false) .soft_wrap(true) .searchable(false) .scroll_beyond_last_line(Some(0)); state.ensure_highlighter_factory(highlighter_factory(rich)); state } /// Turns Markdown styling on or off, live. pub fn enable(state: &Entity, on: bool, cx: &mut App) { state.update(cx, |state, cx| state.set_highlighter_factory(highlighter_factory(on), cx)); } /// Vertical padding the input puts around its text: gpui-component's /// `Size::Medium.input_py()`, the same as the plain composer's. const PADDING_Y: f32 = 8.; /// How much further right an editor starts its text than a textarea: it pads /// 6px instead of 10px, plus a 6px gap where the line numbers would end. const EDITOR_INDENT: f32 = 2.; /// Renders a [`composer_state`] editor as the plain composer would render its /// `Textarea`: borderless, in the UI font at the textarea's size and leading, /// growing with its content between `rows` (default 2 to 12). #[derive(IntoElement)] pub struct MarkdownInput { state: Entity, min_rows: usize, max_rows: usize, } impl MarkdownInput { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), min_rows: 2, max_rows: 12 } } /// Grow with the content from `min` through `max` rows. pub fn rows(mut self, min: usize, max: usize) -> Self { self.min_rows = min.max(1); self.max_rows = max.max(self.min_rows); self } } impl RenderOnce for MarkdownInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { PALETTE.with(|cell| cell.set(Some(metal::palette(cx)))); let (min, max) = (self.min_rows, self.max_rows); let state = self.state.read(cx); let line_height = state.line_height().unwrap_or_else(|| rems(1.25).to_pixels(window.rem_size())); let rows = content_rows(state).unwrap_or(min).clamp(min, max); // The rows above come from the last layout. Once this frame has laid // the text out, measure again and redraw if the content grew or shrank. let entity = self.state.clone(); window.on_next_frame(move |_, cx| { let measured = content_rows(entity.read(cx)).map(|now| now.clamp(min, max)); if measured.is_some_and(|now| now != rows) { cx.notify(entity.entity_id()); } }); let menu_state = self.state.clone(); Editor::new(&self.state) .appearance(false) .h(line_height * rows as f32 + px(PADDING_Y * 2.)) .context_menu(move |menu, window, cx| { // The builder runs while the editor is mid-update, where // reading its state panics, so the real menu waits a tick // and the empty one returned here shows nothing. let state = menu_state.clone(); window.defer(cx, move |window, cx| { let position = window.mouse_position(); context_menu(&state, NativeMenu::new(), cx).show(position, window, cx); }); menu }) .font_family(metal::SANS) .text_size(rems(0.875)) .line_height(rems(1.25)) .ml(px(-EDITOR_INDENT)) } } /// The plain text menu. The editor's own adds code navigation entries. fn context_menu(state: &Entity, menu: NativeMenu, cx: &App) -> NativeMenu { let state = state.read(cx); let editable = state.is_editable(); let selected = !state.selected_range().is_empty(); menu.menu_with_disabled("Cut", !(editable && selected), Box::new(input::Cut)) .menu_with_disabled("Copy", !selected, Box::new(input::Copy)) .menu_with_disabled("Paste", !editable, Box::new(input::Paste)) .separator() .menu("Select All", Box::new(input::SelectAll)) } /// How many rows the editor's text takes up, soft wraps included, from its /// last layout. `None` before the first layout. /// /// The laid-out lines are measured exactly; lines scrolled out of view count /// one row each, which only ever undercounts, so growing converges in a frame /// or two. fn content_rows(state: &EditorState) -> Option { let line_height = state.line_height()?; let text = state.text(); let lines = text.lines_len().max(1); let visible = state.visible_row_range()?; let visible = visible.start.min(lines)..visible.end.min(lines); if visible.is_empty() { return None; } let start = text.line_start_offset(visible.start); let end = text.line_end_offset(visible.end - 1); let top = state.range_to_bounds(&(start..start))?.top(); let bottom = state.range_to_bounds(&(end..end))?.bottom(); let measured = ((bottom - top) / line_height).round().max(1.) as usize; Some(measured + lines - visible.len()) } fn style_for(flags: u16, p: &Palette) -> HighlightStyle { let mut style = HighlightStyle::default(); if flags & (flag::HEADING | flag::STRONG) != 0 { style.font_weight = Some(FontWeight::BOLD); } if flags & flag::EMPHASIS != 0 { // Geist ships no italic face, and GPUI on Linux does not slant one, // so emphasis also gets a softer ink to stay visible. style.font_style = Some(FontStyle::Italic); style.color = Some(p.ink2); } if flags & flag::QUOTE != 0 { style.color = Some(p.ink2); } if flags & flag::LIST_MARKER != 0 { style.color = Some(p.amber_ink); } if flags & flag::COMMAND != 0 { style.color = Some(p.green_ink); style.font_weight = Some(FontWeight::MEDIUM); } if flags & flag::LINK_TEXT != 0 { style.color = Some(p.blue_ink); style.underline = Some(UnderlineStyle { thickness: px(1.), color: Some(p.blue_ink.opacity(0.5)), wavy: false }); } if flags & flag::LINK_URL != 0 { style.color = Some(p.ink3); } if flags & flag::CODE != 0 { style.color = Some(p.ink2); style.background_color = Some(p.well_top); } if flags & flag::SYNTAX != 0 { style.color = Some(p.ink3); } if flags & flag::CODE_BLOCK != 0 { style.color = Some(p.ink2); style.font_weight = None; style.font_style = None; } if flags & flag::FENCE != 0 { style.color = Some(p.ink3); } style } // --------------------------------------------------------------------------- // The scanner // --------------------------------------------------------------------------- /// An open code fence: its character and length. #[derive(Clone, Copy)] struct Fence { ch: u8, len: usize, } /// Scans `text` into styled runs: ordered, non-overlapping, on UTF-8 char /// boundaries, unstyled text left out. fn scan(text: &str) -> Vec { let mut spans = Vec::new(); let mut fence = None; let mut leading = true; let mut flags = Vec::new(); let mut literal = Vec::new(); let mut offset = 0; for raw in text.split('\n') { let line = raw.strip_suffix('\r').unwrap_or(raw); flags.clear(); flags.resize(line.len(), 0u16); literal.clear(); literal.resize(line.len(), false); // The message is sent trimmed, so its first non-blank line leads. let leads = leading && !line.trim().is_empty(); scan_line(line, &mut fence, leads, &mut flags, &mut literal); leading &= !leads; push_runs(&mut spans, offset, &flags); offset += raw.len() + 1; } spans } /// Appends the runs of equal, non-zero flags in one line. fn push_runs(spans: &mut Vec, offset: usize, flags: &[u16]) { let mut start = 0; while start < flags.len() { let value = flags[start]; let mut end = start + 1; while end < flags.len() && flags[end] == value { end += 1; } if value != 0 { spans.push((offset + start..offset + end, value)); } start = end; } } fn mark(flags: &mut [u16], range: Range, value: u16) { for slot in &mut flags[range] { *slot |= value; } } fn indent_of(bytes: &[u8], from: usize) -> usize { bytes[from..].iter().take_while(|&&b| b == b' ' || b == b'\t').count() } fn scan_line(line: &str, fence: &mut Option, leads: bool, flags: &mut [u16], literal: &mut [bool]) { let bytes = line.as_bytes(); let len = bytes.len(); if let Some(open) = *fence { if closes_fence(bytes, open) { *fence = None; mark(flags, 0..len, flag::FENCE); } else { mark(flags, 0..len, flag::CODE_BLOCK); } return; } if let Some(open) = opens_fence(bytes) { *fence = Some(open); mark(flags, 0..len, flag::FENCE); return; } let mut pos = 0; if leads && let Some(end) = leading_command(bytes) { let start = indent_of(bytes, 0); mark(flags, start..end, flag::COMMAND); inline(line, end..len, flags, literal); return; } // Block quotes, nested or not: `>` markers, each with one optional space. loop { let at = pos + indent_of(bytes, pos); if at - pos <= 3 && bytes.get(at) == Some(&b'>') { mark(flags, at..len, flag::QUOTE); mark(flags, at..at + 1, flag::SYNTAX); pos = at + 1; if bytes.get(pos) == Some(&b' ') { pos += 1; } } else { break; } } if is_thematic_break(&bytes[pos..]) { mark(flags, pos..len, flag::SYNTAX); return; } // ATX heading: up to three spaces, one to six `#`, then a space or the end. let at = pos + indent_of(bytes, pos); let hashes = bytes[at..].iter().take_while(|&&b| b == b'#').count(); if at - pos <= 3 && (1..=6).contains(&hashes) && matches!(bytes.get(at + hashes), None | Some(b' ' | b'\t')) { mark(flags, at..len, flag::HEADING); mark(flags, at..at + hashes, flag::SYNTAX); // An optional closing run of `#` after a space. let trimmed = line.trim_end().len(); let closing = bytes[..trimmed].iter().rev().take_while(|&&b| b == b'#').count(); let mut content_end = trimmed; if closing > 0 && trimmed - closing > at + hashes && matches!(bytes[trimmed - closing - 1], b' ' | b'\t') { content_end = trimmed - closing; mark(flags, content_end..trimmed, flag::SYNTAX); } inline(line, (at + hashes).min(content_end)..content_end, flags, literal); return; } // List item: a bullet or a number, then a space or the end, then // optionally a task box. let mut content = pos; if let Some(marker_end) = list_marker(bytes, at) { mark(flags, at..marker_end, flag::LIST_MARKER); content = marker_end; let box_at = marker_end + indent_of(bytes, marker_end); if box_at > marker_end && let [b'[', b' ' | b'x' | b'X', b']', ..] = &bytes[box_at..] && matches!(bytes.get(box_at + 3), None | Some(b' ' | b'\t')) { mark(flags, box_at..box_at + 3, flag::LIST_MARKER); content = box_at + 3; } } inline(line, content..len, flags, literal); } /// An opening fence: three or more backticks or tildes, at any indent (fences /// inside list items are indented). A backtick fence's info string can't hold /// a backtick, or it's inline code. fn opens_fence(bytes: &[u8]) -> Option { let at = indent_of(bytes, 0); let ch = *bytes.get(at)?; if ch != b'`' && ch != b'~' { return None; } let len = bytes[at..].iter().take_while(|&&b| b == ch).count(); if len < 3 || (ch == b'`' && bytes[at + len..].contains(&b'`')) { return None; } Some(Fence { ch, len }) } /// A closing fence: at least as long as the opening one, nothing after it. fn closes_fence(bytes: &[u8], open: Fence) -> bool { let at = indent_of(bytes, 0); let len = bytes[at..].iter().take_while(|&&b| b == open.ch).count(); len >= open.len && bytes[at + len..].iter().all(|b| b.is_ascii_whitespace()) } /// `---`, `***` or `___`, optionally spaced out. fn is_thematic_break(bytes: &[u8]) -> bool { let mut ch = None; let mut count = 0; for &b in bytes { match b { b' ' | b'\t' => {} b'-' | b'*' | b'_' if ch.is_none_or(|c| c == b) => { ch = Some(b); count += 1; } _ => return false, } } count >= 3 } /// The end of a list marker starting at `at`: `-`, `*` or `+`, or up to nine /// digits and `.` or `)`, followed by a space or the end of the line. fn list_marker(bytes: &[u8], at: usize) -> Option { let end = match *bytes.get(at)? { b'-' | b'*' | b'+' => at + 1, b'0'..=b'9' => { let digits = bytes[at..].iter().take_while(|b| b.is_ascii_digit()).count(); if digits > 9 || !matches!(bytes.get(at + digits), Some(b'.' | b')')) { return None; } at + digits + 1 } _ => return None, }; matches!(bytes.get(end), None | Some(b' ' | b'\t')).then_some(end) } /// The end of a `/command` or `$skill` at the start of the line. fn leading_command(bytes: &[u8]) -> Option { let at = indent_of(bytes, 0); let sigil = *bytes.get(at)?; if sigil != b'/' && sigil != b'$' { return None; } let name = bytes[at + 1..] .iter() .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.')) .count(); let first = *bytes.get(at + 1)?; let starts_well = if sigil == b'$' { first.is_ascii_alphabetic() } else { first.is_ascii_alphanumeric() }; let end = at + 1 + name; (name > 0 && starts_well && matches!(bytes.get(end), None | Some(b' ' | b'\t'))).then_some(end) } /// Marks inline Markdown in `range` of `line`: escapes, code spans, links, /// bare URLs and emphasis, in that order of precedence. `literal` collects /// bytes that later passes must not read as syntax. fn inline(line: &str, range: Range, flags: &mut [u16], literal: &mut [bool]) { let bytes = line.as_bytes(); let Range { start, end } = range; // Escapes and code spans, left to right as CommonMark reads them. let mut i = start; while i < end { match bytes[i] { b'\\' if i + 1 < end && bytes[i + 1].is_ascii_punctuation() => { mark(flags, i..i + 1, flag::SYNTAX); literal[i] = true; literal[i + 1] = true; i += 2; } b'`' => { let run = run_len(bytes, i, end, b'`'); if let Some(close) = find_backtick_run(bytes, i + run, end, run) { mark(flags, i..close + run, flag::CODE); mark(flags, i..i + run, flag::SYNTAX); mark(flags, close..close + run, flag::SYNTAX); literal[i..close + run].fill(true); i = close + run; } else { literal[i..i + run].fill(true); i += run; } } _ => i += 1, } } links(line, start..end, flags, literal); bare_urls(line, start..end, flags, literal); emphasis(line, start..end, flags, literal); } fn run_len(bytes: &[u8], at: usize, end: usize, ch: u8) -> usize { bytes[at..end].iter().take_while(|&&b| b == ch).count() } /// The start of the next run of exactly `len` backticks in `from..end`. fn find_backtick_run(bytes: &[u8], from: usize, end: usize, len: usize) -> Option { let mut i = from; while i < end { if bytes[i] == b'`' { let run = run_len(bytes, i, end, b'`'); if run == len { return Some(i); } i += run; } else { i += 1; } } None } /// `[text](url)` and `![alt](url)`. fn links(line: &str, range: Range, flags: &mut [u16], literal: &mut [bool]) { let bytes = line.as_bytes(); let Range { start, end } = range; let mut i = start; while i < end { if bytes[i] != b'[' || literal[i] { i += 1; continue; } let Some(close) = matching(bytes, literal, i, end, b'[', b']') else { i += 1; continue; }; let Some(paren_close) = (bytes.get(close + 1) == Some(&b'(')) .then(|| matching(bytes, literal, close + 1, end, b'(', b')')) .flatten() else { i += 1; continue; }; let open = if i > start && bytes[i - 1] == b'!' && !literal[i - 1] { i - 1 } else { i }; mark(flags, i + 1..close, flag::LINK_TEXT); mark(flags, close + 2..paren_close, flag::LINK_URL); mark(flags, open..i + 1, flag::SYNTAX); mark(flags, close..close + 2, flag::SYNTAX); mark(flags, paren_close..paren_close + 1, flag::SYNTAX); for at in [i, close, close + 1, paren_close] { literal[at] = true; } literal[open] = true; literal[close + 2..paren_close].fill(true); i = paren_close + 1; } } /// The index of the bracket closing the one at `open`, skipping literal bytes. fn matching(bytes: &[u8], literal: &[bool], open: usize, end: usize, left: u8, right: u8) -> Option { let mut depth = 0usize; for i in open..end { if literal[i] && i != open { continue; } if bytes[i] == left { depth += 1; } else if bytes[i] == right { depth -= 1; if depth == 0 { return Some(i); } } } None } /// `http://` and `https://` URLs written out in the text. fn bare_urls(line: &str, range: Range, flags: &mut [u16], literal: &mut [bool]) { let bytes = line.as_bytes(); let mut search = range.start; while let Some(found) = line[search..range.end].find("http") { let at = search + found; let rest = &line[at..range.end]; let scheme = if rest.starts_with("https://") { 8 } else if rest.starts_with("http://") { 7 } else { search = at + 4; continue; }; let word_start = at == range.start || matches!(bytes[at - 1], b' ' | b'\t' | b'(' | b'<' | b'"' | b'\''); if !word_start || literal[at] { search = at + scheme; continue; } let mut end = at + rest.find(|c: char| c.is_whitespace() || c == '<' || c == '>').unwrap_or(rest.len()); while end > at + scheme && matches!(bytes[end - 1], b'.' | b',' | b':' | b';' | b'!' | b'?' | b')' | b'\'' | b'"') { end -= 1; } if end > at + scheme { mark(flags, at..end, flag::LINK_TEXT); literal[at..end].fill(true); } search = end.max(at + scheme); } } /// A run of `*` or `_` that may open or close emphasis. struct Delimiter { ch: u8, /// Where the run's unused delimiters start. start: usize, /// How many are left unused. len: usize, original_len: usize, can_open: bool, can_close: bool, } fn is_punctuation(c: char) -> bool { c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) } /// `*emphasis*`, `_emphasis_`, `**strong**` and `__strong__`, matched with /// CommonMark's delimiter-run rules (flanking, intraword `_`, rule of three). fn emphasis(line: &str, range: Range, flags: &mut [u16], literal: &[bool]) { let bytes = line.as_bytes(); let mut runs: Vec = Vec::new(); let mut i = range.start; while i < range.end { let ch = bytes[i]; if (ch != b'*' && ch != b'_') || literal[i] { i += 1; continue; } let mut len = 1; while i + len < range.end && bytes[i + len] == ch && !literal[i + len] { len += 1; } let before = line[..i].chars().next_back().unwrap_or(' '); let after = line[i + len..].chars().next().unwrap_or(' '); let left = !after.is_whitespace() && (!is_punctuation(after) || before.is_whitespace() || is_punctuation(before)); let right = !before.is_whitespace() && (!is_punctuation(before) || after.is_whitespace() || is_punctuation(after)); let (can_open, can_close) = if ch == b'*' { (left, right) } else { (left && (!right || is_punctuation(before)), right && (!left || is_punctuation(after))) }; if can_open || can_close { runs.push(Delimiter { ch, start: i, len, original_len: len, can_open, can_close }); } i += len; } for closer in 0..runs.len() { if !runs[closer].can_close { continue; } while runs[closer].len > 0 { let opener = (0..closer).rev().find(|&o| { let (o, c) = (&runs[o], &runs[closer]); let odd_match = (o.can_close || c.can_open) && (o.original_len + c.original_len) % 3 == 0 && !(o.original_len % 3 == 0 && c.original_len % 3 == 0); o.ch == c.ch && o.can_open && o.len > 0 && !odd_match }); let Some(opener) = opener else { break }; let used = if runs[opener].len >= 2 && runs[closer].len >= 2 { 2 } else { 1 }; let open_end = runs[opener].start + runs[opener].len; let close_start = runs[closer].start; mark(flags, open_end - used..open_end, flag::SYNTAX); mark(flags, close_start..close_start + used, flag::SYNTAX); mark(flags, open_end..close_start, if used == 2 { flag::STRONG } else { flag::EMPHASIS }); runs[opener].len -= used; runs[closer].start += used; runs[closer].len -= used; for between in &mut runs[opener + 1..closer] { between.len = 0; } } } } #[cfg(test)] mod tests { // Not `super::*`: GPUI's glob exports its own `test` attribute. use std::ops::Range; use gpui_kit::{FontWeight, HighlightStyle}; use super::{MarkdownHighlighter, flag, metal, scan}; /// The flags of the bytes covered by `needle`'s first occurrence, which /// must all agree. fn flags_of(text: &str, needle: &str) -> u16 { let at = text.find(needle).unwrap_or_else(|| panic!("{needle:?} not in {text:?}")); flags_at(text, at..at + needle.len()) } fn flags_at(text: &str, range: Range) -> u16 { let spans = scan(text); let value = |offset: usize| { spans.iter().find(|(span, _)| span.contains(&offset)).map_or(0, |(_, flags)| *flags) }; let first = value(range.start); for offset in range.clone() { assert_eq!(value(offset), first, "mixed flags in {:?} of {text:?}", &text[range.clone()]); } first } fn assert_well_formed(text: &str) { let spans = scan(text); let mut last = 0; for (span, flags) in &spans { assert!(span.start >= last && span.start < span.end, "{spans:?}"); assert!(*flags != 0); assert!(text.is_char_boundary(span.start) && text.is_char_boundary(span.end), "{span:?}"); assert!(span.end <= text.len()); last = span.end; } } /// `styles` must cover any requested range exactly, in order. fn assert_covers(text: &str, range: Range) { let mut highlighter = MarkdownHighlighter::new(); highlighter.rescan(text); let runs = highlighter.styles_with(&range, &metal::BONE); let mut at = range.start; for (run, _) in &runs { assert_eq!(run.start, at, "gap or overlap in {runs:?}"); assert!(run.end > run.start, "empty run in {runs:?}"); assert!(text.is_char_boundary(run.start) && text.is_char_boundary(run.end)); at = run.end; } assert_eq!(at, range.end, "runs end early: {runs:?}"); } #[test] fn headings() { let text = "# Title\n###### Six\n####### seven\n#hashtag\n## Closed ##"; assert_eq!(flags_of(text, "#"), flag::HEADING | flag::SYNTAX); assert_eq!(flags_of(text, "Title"), flag::HEADING); assert_eq!(flags_of(text, "Six"), flag::HEADING); assert_eq!(flags_of(text, "seven"), 0); assert_eq!(flags_of(text, "#hashtag"), 0); assert_eq!(flags_of(text, "Closed"), flag::HEADING); let closing = text.rfind("##").unwrap(); assert_eq!(flags_at(text, closing..closing + 2), flag::HEADING | flag::SYNTAX); assert_eq!(flags_of("# **Bold** head", "Bold"), flag::HEADING | flag::STRONG); } #[test] fn emphasis_and_strong() { let text = "a *one* b **two** c _three_ d __four__ e ***five***"; assert_eq!(flags_of(text, "one"), flag::EMPHASIS); assert_eq!(flags_of(text, "two"), flag::STRONG); assert_eq!(flags_of(text, "three"), flag::EMPHASIS); assert_eq!(flags_of(text, "four"), flag::STRONG); assert_eq!(flags_of(text, "five"), flag::STRONG | flag::EMPHASIS); assert_eq!(flags_of(text, "**"), flag::SYNTAX); assert_eq!(flags_of(text, " b "), 0); } #[test] fn emphasis_that_is_not() { // Intraword underscores, spaced-out stars and unclosed runs stay text. for text in ["snake_case_name", "2 * 3 * 4", "a **b", "* not a list*either", "x__y__z"] { let spans = scan(text); assert!(spans.iter().all(|(_, flags)| flags & (flag::EMPHASIS | flag::STRONG) == 0), "{text:?}: {spans:?}"); } // Stars inside a word still do. assert_eq!(flags_of("un*frigging*believable", "frigging"), flag::EMPHASIS); } #[test] fn nested_emphasis() { let text = "**bold *both* bold**"; assert_eq!(flags_of(text, "both"), flag::STRONG | flag::EMPHASIS); assert_eq!(flags_of(text, "bold "), flag::STRONG); } #[test] fn inline_code() { let text = "run `cargo *test*` now and ``a ` b``"; assert_eq!(flags_of(text, "cargo *test*"), flag::CODE); assert_eq!(flags_of(text, "`"), flag::CODE | flag::SYNTAX); assert_eq!(flags_of(text, "a ` b"), flag::CODE); assert_eq!(flags_of(text, " now "), 0); // An unmatched backtick is just a backtick. assert_eq!(scan("a ` b"), vec![]); } #[test] fn escapes() { let text = r"\*not emphasis\* and \`not code\`"; assert_eq!(flags_of(text, "not emphasis"), 0); assert_eq!(flags_of(text, "not code"), 0); assert_eq!(flags_at(text, 0..1), flag::SYNTAX); } /// The flags of line `n`, which must agree across it. fn line_flags(text: &str, n: usize) -> u16 { let start: usize = text.split('\n').take(n).map(|line| line.len() + 1).sum(); let line = text.split('\n').nth(n).unwrap(); flags_at(text, start..start + line.len()) } #[test] fn fences_span_lines() { let text = "before\n```rust\nlet x = *y*;\n# not a heading\n```\nafter *em*"; assert_eq!(line_flags(text, 0), 0); assert_eq!(line_flags(text, 1), flag::FENCE); assert_eq!(line_flags(text, 2), flag::CODE_BLOCK); assert_eq!(line_flags(text, 3), flag::CODE_BLOCK); assert_eq!(line_flags(text, 4), flag::FENCE); assert_eq!(flags_of(text, "em"), flag::EMPHASIS); } #[test] fn fences_need_a_matching_close() { // A shorter run or another character doesn't close; an unclosed fence // runs to the end, as it does on GitHub. let text = "~~~~\n~~~\n```\nstill code\n~~~~~\nout"; assert_eq!(line_flags(text, 0), flag::FENCE); assert_eq!(line_flags(text, 1), flag::CODE_BLOCK); assert_eq!(line_flags(text, 2), flag::CODE_BLOCK); assert_eq!(line_flags(text, 3), flag::CODE_BLOCK); assert_eq!(line_flags(text, 4), flag::FENCE); assert_eq!(line_flags(text, 5), 0); assert_eq!(line_flags("```\nopen\nto the end", 2), flag::CODE_BLOCK); // Backticks in the info string make it inline code, not a fence. assert_eq!(flags_of("```a``` b", "b"), 0); assert_eq!(flags_of("```a``` b", "a"), flag::CODE); // Indented fences count, as inside list items. assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "code"), flag::CODE_BLOCK); assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "out"), 0); } #[test] fn quotes_lists_and_rules() { let text = "> quoted *em*"; assert_eq!(flags_of(text, ">"), flag::QUOTE | flag::SYNTAX); assert_eq!(flags_of(text, "quoted"), flag::QUOTE); assert_eq!(flags_of(text, "em"), flag::QUOTE | flag::EMPHASIS); for (text, marker) in [("- item", "-"), ("* star", "*"), ("+ plus", "+"), ("12. twelve", "12."), ("3) three", "3)"), (" - nested", "-")] { assert_eq!(flags_of(text, marker), flag::LIST_MARKER, "{text:?}"); assert_eq!(flags_at(text, text.len() - 3..text.len()), 0, "{text:?}"); } assert_eq!(flags_of("- [x] done", "[x]"), flag::LIST_MARKER); assert_eq!(flags_of("- [x] done", "done"), 0); assert_eq!(flags_of("---", "---"), flag::SYNTAX); assert_eq!(flags_of("* * *", "* * *"), flag::SYNTAX); assert_eq!(flags_of("-not", "-not"), 0); assert_eq!(flags_of("1.5 times", "1.5"), 0); assert_eq!(flags_of("> - nested", "-"), flag::QUOTE | flag::LIST_MARKER); } #[test] fn links_and_urls() { let text = "see [the docs](https://x.dev/a_b_c) and ![img](p.png) or https://t3.chat/x."; assert_eq!(flags_of(text, "the docs"), flag::LINK_TEXT); assert_eq!(flags_of(text, "https://x.dev/a_b_c"), flag::LINK_URL); assert_eq!(flags_of(text, "]("), flag::SYNTAX); assert_eq!(flags_of(text, "![") , flag::SYNTAX); assert_eq!(flags_of(text, "https://t3.chat/x"), flag::LINK_TEXT); assert_eq!(flags_at(text, text.len() - 1..text.len()), 0); assert_eq!(flags_of("[not a link] (x)", "not a link"), 0); } #[test] fn leading_commands() { assert_eq!(flags_of("/review this *now*", "/review"), flag::COMMAND); assert_eq!(flags_of("/review this *now*", "now"), flag::EMPHASIS); assert_eq!(flags_of("\n $deploy-prod please", "$deploy-prod"), flag::COMMAND); assert_eq!(flags_of("/plugin:cmd", "/plugin:cmd"), flag::COMMAND); // Not leading, a path, or a price: plain text. assert_eq!(flags_of("hi\n/review", "/review"), 0); assert_eq!(flags_of("/home/joni is mine", "/home"), 0); assert_eq!(flags_of("$5 please", "$5"), 0); } #[test] fn unicode_text() { let text = "# Grüße 👋\n**fett** und *kursiv* — `naïve`\n> ünïcödé [lïnk](ü)\n日本語の*強調*です"; assert_well_formed(text); assert_eq!(flags_of(text, "Grüße 👋"), flag::HEADING); assert_eq!(flags_of(text, "fett"), flag::STRONG); assert_eq!(flags_of(text, "kursiv"), flag::EMPHASIS); assert_eq!(flags_of(text, "naïve"), flag::CODE); assert_eq!(flags_of(text, "lïnk"), flag::QUOTE | flag::LINK_TEXT); assert_eq!(flags_of(text, "強調"), flag::EMPHASIS); for end in (0..=text.len()).filter(|&i| text.is_char_boundary(i)) { assert_covers(text, 0..end); assert_covers(text, end..text.len()); } } #[test] fn styles_cover_every_line_range() { let text = "# H\n\n- a **b** `c`\r\n```\nx\n```\n> q [l](u)\n__u__ *e* ~ end"; assert_well_formed(text); let mut starts = vec![0]; starts.extend(text.match_indices('\n').map(|(i, _)| i + 1)); starts.push(text.len()); for (i, &start) in starts.iter().enumerate() { for &end in &starts[i..] { assert_covers(text, start..end); } } assert_covers("", 0..0); assert!(MarkdownHighlighter::new().styles_with(&(0..0), &metal::BONE).is_empty()); } #[test] fn crlf_lines() { let text = "**a**\r\n# b\r\n"; assert_well_formed(text); assert_eq!(flags_of(text, "a"), flag::STRONG); assert_eq!(flags_of(text, "b"), flag::HEADING); assert_eq!(flags_of(text, "\r"), 0); } #[test] fn styles_follow_flags() { let mut highlighter = MarkdownHighlighter::new(); let text = "**b** `c`"; highlighter.rescan(text); let runs = highlighter.styles_with(&(0..text.len()), &metal::GRAPHITE); let style_at = |offset: usize| runs.iter().find(|(run, _)| run.contains(&offset)).unwrap().1; assert_eq!(style_at(2).font_weight, Some(FontWeight::BOLD)); assert_eq!(style_at(0).color, Some(metal::GRAPHITE.ink3)); assert_eq!(style_at(7).background_color, Some(metal::GRAPHITE.well_top)); assert_eq!(style_at(5), HighlightStyle::default()); } #[test] fn a_few_hundred_lines_scan_fast() { let block = "# Heading\nSome *text* with **bold**, `code` and [a link](https://x.y).\n- item one\n```\nfn main() {}\n```\n> quote\n"; let text = block.repeat(100); let started = std::time::Instant::now(); for _ in 0..10 { assert!(!scan(&text).is_empty()); } assert!(started.elapsed() < std::time::Duration::from_millis(200), "{:?}", started.elapsed()); } }