Files
Ely-GPUI-Components/src/documents/markdown.rs
T
ZacharyZhang-NYandClaude Opus 5.5 4801555e0d
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 11m44s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
documents: pictures draw in table cells; Markdown-path comments within ten words
A cell holds blocks, split at its pictures as a paragraph is. A picture
takes its natural width, capped by its container.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:12:16 -04:00

318 lines
10 KiB
Rust

use std::ops::Range;
use gpui::{App, FontWeight};
use markdown::{Constructs, ParseOptions, mdast};
use unicode_segmentation::UnicodeSegmentation;
use crate::{forms::Highlight, theme::ActiveTheme};
/// GitHub's flavor, and math between dollars.
pub(crate) fn options() -> ParseOptions {
ParseOptions {
constructs: Constructs {
math_flow: true,
math_text: true,
..Constructs::gfm()
},
..ParseOptions::gfm()
}
}
/// `text` as a syntax tree.
pub(crate) fn tree(text: &str) -> mdast::Node {
markdown::to_mdast(text, &options()).expect("markdown without MDX always parses")
}
/// A whole file as a syntax tree: front matter too.
pub(crate) fn file_tree(text: &str) -> mdast::Node {
let mut options = options();
options.constructs.frontmatter = true;
markdown::to_mdast(text, &options).expect("markdown without MDX always parses")
}
/// The bytes of the source a parsed node spans.
pub(crate) fn span(node: &mdast::Node) -> Range<usize> {
let at = node.position().expect("a parsed node has its position");
at.start.offset..at.end.offset
}
/// How a stretch of markdown prints while it is edited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mark {
Strong,
Emphasis,
Strike,
Code,
Link,
Heading(u8),
Quote,
/// The characters that write the marks: asterisks, backticks, brackets, hashes.
Syntax,
}
/// The run of `ch` that starts `text`, in bytes.
fn leading(text: &str, ch: char) -> usize {
text.chars()
.take_while(|found| *found == ch)
.map(char::len_utf8)
.sum()
}
/// Bytes at each end of `span` that write `mark`.
pub(crate) fn edge(span: &str, mark: Mark) -> usize {
let first = span.chars().next().expect("a styled span has its marker");
let run = leading(span, first).min(span.len() / 2);
match mark {
Mark::Emphasis => run.min(first.len_utf8()),
_ => run,
}
}
/// Every node under `node`, itself first, in source order.
pub(crate) fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) {
out.push(node);
for child in node.children().into_iter().flatten() {
walk(child, out);
}
}
/// The marks over `text`, with the syntax writing each.
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
let root = tree(text);
let mut nodes = Vec::new();
walk(&root, &mut nodes);
let mut out = Vec::new();
for node in nodes {
let range = span(node);
let written = &text[range.clone()];
let styled = match node {
mdast::Node::Strong(_) => Some(Mark::Strong),
mdast::Node::Emphasis(_) => Some(Mark::Emphasis),
mdast::Node::Delete(_) => Some(Mark::Strike),
mdast::Node::InlineCode(_) => Some(Mark::Code),
_ => None,
};
if let Some(mark) = styled {
let edge = edge(written, mark);
out.push((range.clone(), mark));
out.push((range.start..range.start + edge, Mark::Syntax));
out.push((range.end - edge..range.end, Mark::Syntax));
continue;
}
match node {
mdast::Node::Link(mdast::Link { children, .. })
| mdast::Node::LinkReference(mdast::LinkReference { children, .. }) => {
out.push((range.clone(), Mark::Link));
if let (Some(first), Some(last)) = (children.first(), children.last()) {
let inner = span(first).start..span(last).end;
out.push((range.start..inner.start, Mark::Syntax));
out.push((inner.end..range.end, Mark::Syntax));
}
}
mdast::Node::Heading(heading) => {
let hashes = leading(written, '#');
out.push((range.clone(), Mark::Heading(heading.depth)));
if hashes > 0 {
let spaced = hashes + leading(&written[hashes..], ' ');
out.push((range.start..range.start + spaced, Mark::Syntax));
}
}
mdast::Node::Blockquote(_) => {
out.push((range.clone(), Mark::Quote));
let mut at = range.start;
for line in written.split_inclusive('\n') {
let marker = line.len() - line.trim_start_matches(['>', ' ']).len();
out.push((at..at + marker, Mark::Syntax));
at += line.len();
}
}
_ => {}
}
}
out.retain(|(range, _)| !range.is_empty());
out
}
/// Markdown styled as typed; its syntax recedes.
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let colors = &cx.theme().colors;
let plain = Highlight::new(colors.fg);
marks(text)
.into_iter()
.map(|(range, mark)| {
let range = match mark {
Mark::Strike => {
let span = &text[range.clone()];
let edge = span.len() - span.trim_start_matches('~').len();
range.start + edge..range.end - edge
}
_ => range,
};
let highlight = match mark {
Mark::Strong | Mark::Heading(_) => Highlight {
weight: Some(FontWeight::SEMIBOLD),
..plain
},
Mark::Emphasis => Highlight {
italic: true,
..plain
},
Mark::Strike => Highlight {
strike: true,
..Highlight::new(colors.fg_muted)
},
Mark::Code => Highlight {
background: Some(colors.sunken),
..plain
},
Mark::Link => Highlight::new(colors.link),
Mark::Quote => Highlight::new(colors.fg_muted),
Mark::Syntax => Highlight::new(colors.fg_subtle),
};
(range, highlight)
})
.collect()
}
/// The `[text](address)` link around byte `at`, if any.
pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> {
let root = tree(text);
let mut nodes = Vec::new();
walk(&root, &mut nodes);
nodes.into_iter().find_map(|node| match node {
mdast::Node::Link(link) => {
let range = span(node);
let written = &text[range.clone()];
if !(range.start <= at && at <= range.end && written.starts_with('[')) {
return None;
}
let label = &written[1..written.rfind("](")?];
Some((range, label.to_string(), link.url.clone()))
}
_ => None,
})
}
/// Top-level blocks as source ranges; definitions excluded.
pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
let root = tree(text);
root.children()
.expect("the root holds the blocks")
.iter()
.filter(|block| !matches!(block, mdast::Node::Definition(_)))
.map(|block| {
let range = span(block);
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
range.start..range.start + kept
})
.collect()
}
/// Words in `text`; each ideograph counts as one.
pub fn words(text: &str) -> usize {
text.unicode_words().count()
}
/// Words an adult reads in a minute, on average.
const PACE: usize = 238;
/// Minutes to read `words`; at least one if any.
pub fn reading_minutes(words: usize) -> usize {
words.div_ceil(PACE)
}
#[cfg(test)]
mod tests {
use super::*;
fn found(text: &str) -> Vec<(&str, Mark)> {
marks(text)
.into_iter()
.map(|(range, mark)| (&text[range], mark))
.collect()
}
#[test]
fn styles_carry_their_markers_as_syntax() {
use Mark::*;
assert_eq!(
found("a **bold** and *it* ~~no~~"),
[
("**bold**", Strong),
("**", Syntax),
("**", Syntax),
("*it*", Emphasis),
("*", Syntax),
("*", Syntax),
("~~no~~", Strike),
("~~", Syntax),
("~~", Syntax),
]
);
}
#[test]
fn code_links_and_headings_mark_their_syntax() {
use Mark::*;
assert_eq!(found("`x`"), [("`x`", Code), ("`", Syntax), ("`", Syntax)]);
assert_eq!(
found("see [the docs](https://ely.dev)"),
[
("[the docs](https://ely.dev)", Link),
("[", Syntax),
("](https://ely.dev)", Syntax)
]
);
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
}
#[test]
fn reference_links_mark_their_syntax() {
use Mark::*;
let defined = "\n\n[site]: https://ely.dev\n";
for (link, open, close) in [
("[the site][site]", "[", "][site]"),
("[site][]", "[", "][]"),
("[site]", "[", "]"),
] {
let text = format!("{link}{defined}");
assert_eq!(
found(&text),
[(link, Link), (open, Syntax), (close, Syntax)],
"{link}"
);
}
}
#[test]
fn a_link_gives_its_range_text_and_address() {
let text = "see [the docs](https://ely.dev) or <https://x.dev>";
assert_eq!(
link_at(text, 6),
Some((4..31, "the docs".to_string(), "https://ely.dev".to_string()))
);
assert_eq!(link_at(text, 1), None);
assert_eq!(link_at(text, 40), None, "an autolink has no text to edit");
}
#[test]
fn blocks_are_the_top_level_stretches() {
let text = "# Lift\n\nBlend **it**.\n\n- a\n- b\n\n---\n";
let blocks: Vec<&str> = block_ranges(text)
.into_iter()
.map(|range| &text[range])
.collect();
assert_eq!(blocks, ["# Lift", "Blend **it**.", "- a\n- b", "---"]);
}
#[test]
fn reading_takes_a_minute_per_pace() {
assert_eq!(words("Blend toward white, by lift."), 5);
assert_eq!(words("你好世界"), 4);
assert_eq!(
(reading_minutes(0), reading_minutes(1), reading_minutes(239)),
(0, 1, 2)
);
}
}