//! 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<usize>, u16);
thread_local! {
/// The colorway 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 colorway is
/// active app-wide, so one cell serves every composer.
static PALETTE: Cell<Option<&'static Palette>> = 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<Span>,
}
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<usize>, palette: &Palette) -> Vec<(Range<usize>, 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<InputEdit>,
text: &Rope,
_folding: bool,
_window: &mut Window,
cx: &mut Context<EditorState>,
) {
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<EditorState>,
) {
// 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<usize>,
_resolver: &dyn HighlightStyleResolver,
) -> Vec<(Range<usize>, 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<FoldRange> {
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<dyn InputHighlighter>))
} 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<EditorState>, 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<EditorState>,
min_rows: usize,
max_rows: usize,
}
impl MarkdownInput {
pub fn new(state: &Entity<EditorState>) -> 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, _, cx| context_menu(&menu_state, menu, cx))
.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<EditorState>, 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<usize> {
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<Span> {
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<Span>, 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<usize>, 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<Fence>, 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<Fence> {
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<usize> {
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<usize> {
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<usize>, 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<usize> {
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 ``.
fn links(line: &str, range: Range<usize>, 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<usize> {
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<usize>, 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<usize>, flags: &mut [u16], literal: &[bool]) {
let bytes = line.as_bytes();
let mut runs: Vec<Delimiter> = 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<usize>) -> 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<usize>) {
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  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());
}
}