markdown/parse/inline.rs
1//! Inline post-processing: mentions, links, marks and one-line collapsing.
2
3use super::*;
4
5/// A mention the shorthand cannot spell says its name instead.
6///
7/// [`Form::Auto`] records that `<url>` was written. Where the angles cannot be
8/// written back — a `mailto:`, a boundary inside a span emitted whole — the
9/// form settles here, so the document already holds what the next parse would
10/// produce. A mention alone in its paragraph passes and stays `Auto`, which is
11/// what leaves it to become a card.
12pub(super) fn settle_mentions(text: &mut Text) {
13 let settled: Vec<usize> = (0..text.marks.len())
14 .filter(|ix| {
15 matches!(
16 text.marks[*ix].mark,
17 Mark::Mention {
18 form: Form::Auto,
19 ..
20 }
21 ) && !is_shorthand(text, *ix)
22 })
23 .collect();
24 for ix in settled {
25 if let Mark::Mention { form, .. } = &mut text.marks[ix].mark {
26 *form = Form::Chip;
27 }
28 }
29}
30
31/// Whether the mark at `ix` can be written with the `<url>` shorthand.
32///
33/// The angles hold a bare URL and nothing else, so a mention has to *be* its
34/// URL: `<mailto:x>` is an autolink this cannot spell that way, and
35/// `**<https://x>**` has a boundary inside a span that is written whole and so
36/// has nowhere to put it. Everything that fails here still has the explicit
37/// spelling to fall back on, which is why nothing ever has to stop being a
38/// mention.
39pub(crate) fn is_shorthand(text: &Text, ix: usize) -> bool {
40 let span = &text.marks[ix];
41 let Mark::Mention { url, form } = &span.mark else {
42 return false;
43 };
44 *form == Form::Auto
45 && text.text.get(span.range.clone()) == Some(url.as_str())
46 && is_link(url)
47 && text.alone(ix)
48}
49
50/// The schemes a bare URL may carry. Narrow on purpose: a scheme and no
51/// whitespace. Anything cleverer starts linking text that merely contains a dot.
52pub(super) const SCHEMES: [&str; 2] = ["https://", "http://"];
53
54/// Every bare URL in `text`, as byte ranges.
55///
56/// One scan answers two questions that have to agree: what [`linkify`] marks,
57/// and what [`crate::serialize`] may write without brackets. Split them and the
58/// round trip drifts the first time the two disagree about a trailing bracket.
59pub(crate) fn urls(text: &str) -> Vec<Range<usize>> {
60 let mut found = Vec::new();
61 let mut at = 0;
62 while at < text.len() {
63 let Some((start, scheme)) = SCHEMES
64 .iter()
65 .filter_map(|scheme| text[at..].find(scheme).map(|ix| (at + ix, *scheme)))
66 .min_by_key(|(ix, _)| *ix)
67 else {
68 break;
69 };
70 let stop = text[start..]
71 .find(char::is_whitespace)
72 .map_or(text.len(), |ix| start + ix);
73 let end = start + trim_url(&text[start..stop]);
74 // A scheme mid-word belongs to the word, and a scheme with no host
75 // behind it is not a URL.
76 let opens = text[..start]
77 .chars()
78 .next_back()
79 .is_none_or(|c| !c.is_alphanumeric());
80 if opens && end > start + scheme.len() {
81 found.push(start..end);
82 }
83 at = stop.max(start + 1);
84 }
85 found
86}
87
88/// Whether `source` is one URL of any scheme, `scheme://rest` — what an
89/// explicit link may hold, the way a CommonMark autolink takes any scheme.
90/// [`is_url`] is the narrower question of what bare text links itself as.
91pub fn is_link(source: &str) -> bool {
92 let Some((scheme, rest)) = source.split_once("://") else {
93 return false;
94 };
95 let mut chars = scheme.chars();
96 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
97 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
98 && !rest.is_empty()
99 && !source.contains(char::is_whitespace)
100}
101
102/// Whether `source` is exactly one bare URL, and nothing else.
103///
104/// The question an editor asks of a paste, and the one [`crate::serialize`]
105/// asks before writing a link bare — the same question, so it is one function.
106pub fn is_url(source: &str) -> bool {
107 matches!(urls(source).as_slice(), [only] if *only == (0..source.len()))
108}
109
110/// Whether a URL or a path names a picture, by the only thing either says
111/// about itself without being fetched — its extension, against what gpui can
112/// decode.
113///
114/// What decides whether a paste or a drop is worth offering as an image. A
115/// server is free to disagree; the answer is a guess about a name, and the
116/// alternative is a menu row that paints a broken box.
117pub fn is_image(source: &str) -> bool {
118 let path = source.split(['?', '#']).next().unwrap_or(source);
119 let Some((_, extension)) = path.rsplit_once('.') else {
120 return false;
121 };
122 matches!(
123 extension.to_ascii_lowercase().as_str(),
124 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" | "avif"
125 )
126}
127
128/// How much of a run the URL is. Closing punctuation belongs to the sentence,
129/// and a bracket only belongs to the URL when the URL opened it.
130pub(super) fn trim_url(run: &str) -> usize {
131 let mut end = run.len();
132 while let Some(last) = run[..end].chars().next_back() {
133 let keep = match last {
134 '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => false,
135 ')' => run[..end].matches('(').count() >= run[..end].matches(')').count(),
136 ']' => run[..end].matches('[').count() >= run[..end].matches(']').count(),
137 _ => true,
138 };
139 if keep {
140 break;
141 }
142 end -= last.len_utf8();
143 }
144 end
145}
146
147/// Mark the bare URLs in a run.
148///
149/// CommonMark links `<http://x>` and nothing else, so a URL typed on its own
150/// arrives as text. Marking it here is what lets a reader click it, what lets
151/// [`crate::serialize`] write it back without brackets, and what makes a URL
152/// alone in a block a [`BlockKind::Bookmark`].
153pub(super) fn linkify(text: &mut Text) {
154 let fresh: Vec<Range<usize>> = urls(&text.text)
155 .into_iter()
156 .filter(|range| {
157 // A URL already inside a link, an image target or a code span is
158 // spelled by that mark, not by this one.
159 !text.marks.iter().any(|span| {
160 matches!(
161 span.mark,
162 Mark::Link(_) | Mark::Mention { .. } | Mark::Image(_) | Mark::Code
163 ) && span.range.start < range.end
164 && range.start < span.range.end
165 })
166 })
167 .collect();
168 for range in fresh {
169 let url = text.text[range.clone()].to_string();
170 text.marks.push(MarkSpan {
171 range,
172 mark: Mark::Link(url),
173 });
174 }
175}
176
177/// Drop the whitespace markdown itself drops, and move the marks with it.
178///
179/// Leading and trailing spaces on a line are not content — one trailing space
180/// is insignificant, two are a hard break, and a continuation line's indent
181/// belongs to block structure. Keeping them would mean writing out whitespace
182/// that the next parse discards, so the document would change every time it was
183/// saved. Blank lines at either end of a block go the same way.
184pub(crate) fn normalize(text: &str, marks: &[MarkSpan]) -> Text {
185 let bytes = text.as_bytes();
186 let mut keep = vec![true; text.len()];
187
188 let mut line_begin = 0;
189 for offset in memchr_newlines(text).chain([text.len()]) {
190 let line = &text[line_begin..offset];
191 let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
192 let trail = line.len() - line.trim_end_matches([' ', '\t']).len();
193 keep[line_begin..line_begin + lead].fill(false);
194 keep[offset - trail..offset].fill(false);
195 line_begin = offset + 1;
196 }
197
198 let mut head = 0;
199 while head < text.len() && (!keep[head] || bytes[head] == b'\n') {
200 keep[head] = false;
201 head += 1;
202 }
203 let mut tail = text.len();
204 while tail > 0 && (!keep[tail - 1] || bytes[tail - 1] == b'\n') {
205 keep[tail - 1] = false;
206 tail -= 1;
207 }
208
209 let mut out = String::with_capacity(text.len());
210 let mut map = vec![0; text.len() + 1];
211 for (offset, ch) in text.char_indices() {
212 map[offset] = out.len();
213 if keep[offset] {
214 out.push(ch);
215 }
216 }
217 map[text.len()] = out.len();
218
219 let marks = marks
220 .iter()
221 .map(|span| MarkSpan {
222 range: map[span.range.start]..map[span.range.end],
223 mark: span.mark.clone(),
224 })
225 // A mark left covering nothing has no spelling that survives a
226 // round trip — `****` is literal text, not empty bold. An image is the
227 // exception: `` is exactly a mark over no alt text.
228 .filter(|span| !span.range.is_empty() || matches!(span.mark, Mark::Image(_)))
229 .collect();
230
231 Text {
232 text: out,
233 marks: merge_same_mark(marks),
234 }
235}
236
237/// Fuse spans of the same mark that overlap or nest.
238///
239/// Emphasis inside the same emphasis is redundant — `_a _b_ c_` is italic
240/// either way — and two spans of one mark have no unambiguous spelling: written
241/// back out, the delimiters pair up differently than they came in. Collapsing
242/// them here means the parse produces the one form that survives being written
243/// and read again.
244pub(super) fn merge_same_mark(mut marks: Vec<MarkSpan>) -> Vec<MarkSpan> {
245 let mut ix = 0;
246 while ix < marks.len() {
247 let mut fused = None;
248 for other in ix + 1..marks.len() {
249 let (a, b) = (&marks[ix], &marks[other]);
250 if a.mark == b.mark
251 && !matches!(a.mark, Mark::Image(_) | Mark::Mention { .. })
252 && a.range.start <= b.range.end
253 && b.range.start <= a.range.end
254 {
255 fused = Some((
256 other,
257 a.range.start.min(b.range.start),
258 a.range.end.max(b.range.end),
259 ));
260 break;
261 }
262 }
263 match fused {
264 Some((other, start, end)) => {
265 marks[ix].range = start..end;
266 marks.remove(other);
267 }
268 None => ix += 1,
269 }
270 }
271 marks
272}
273
274/// Flatten a block whose serialized form is one line.
275///
276/// A setext heading (`Title\n=====`) and a table cell can both hold a line
277/// break that has nowhere to go in the output — an ATX `#` heading ends at its
278/// newline, and a second line in a cell would end the row. Both are single-line
279/// blocks in this model, and since a newline and a space are each one byte, the
280/// marks over them do not move.
281pub(crate) fn collapse_to_one_line(text: &mut Text) {
282 if text.text.contains('\n') {
283 text.text = text.text.replace('\n', " ");
284 }
285}
286
287/// Split a trailing `|480` off an image's alt text, which is where a width is
288/// written down.
289///
290/// Obsidian's spelling, and the only one the parser leaves intact: `{width=480}`
291/// trails as literal text and breaks the paragraph out of being an image at all,
292/// and `=480x` is not an image to begin with. The last `|` wins, so a caption
293/// may hold its own — but one *ending* in `|123` gives that tail up, because the
294/// escape that tells them apart on disk is gone by the time this reads it.
295pub(super) fn split_width(alt: &str) -> (&str, Option<u32>) {
296 let Some((caption, tail)) = alt.rsplit_once('|') else {
297 return (alt, None);
298 };
299 // A zero would paint a picture no pixels wide, and nothing that writes one
300 // can produce it — the drag floors at `MIN_IMAGE_WIDTH`.
301 match tail.parse().ok().filter(|width| *width > 0) {
302 Some(width) => (caption, Some(width)),
303 None => (alt, None),
304 }
305}
306
307pub(super) fn memchr_newlines(text: &str) -> impl Iterator<Item = usize> + '_ {
308 text.bytes()
309 .enumerate()
310 .filter_map(|(ix, b)| (b == b'\n').then_some(ix))
311}