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_url(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 exactly one bare URL, and nothing else.
89///
90/// The question an editor asks of a paste, and the one [`crate::serialize`]
91/// asks before writing a link bare — the same question, so it is one function.
92pub fn is_url(source: &str) -> bool {
93 matches!(urls(source).as_slice(), [only] if *only == (0..source.len()))
94}
95
96/// Whether a URL or a path names a picture, by the only thing either says
97/// about itself without being fetched — its extension, against what gpui can
98/// decode.
99///
100/// What decides whether a paste or a drop is worth offering as an image. A
101/// server is free to disagree; the answer is a guess about a name, and the
102/// alternative is a menu row that paints a broken box.
103pub fn is_image(source: &str) -> bool {
104 let path = source.split(['?', '#']).next().unwrap_or(source);
105 let Some((_, extension)) = path.rsplit_once('.') else {
106 return false;
107 };
108 matches!(
109 extension.to_ascii_lowercase().as_str(),
110 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" | "avif"
111 )
112}
113
114/// How much of a run the URL is. Closing punctuation belongs to the sentence,
115/// and a bracket only belongs to the URL when the URL opened it.
116pub(super) fn trim_url(run: &str) -> usize {
117 let mut end = run.len();
118 while let Some(last) = run[..end].chars().next_back() {
119 let keep = match last {
120 '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => false,
121 ')' => run[..end].matches('(').count() >= run[..end].matches(')').count(),
122 ']' => run[..end].matches('[').count() >= run[..end].matches(']').count(),
123 _ => true,
124 };
125 if keep {
126 break;
127 }
128 end -= last.len_utf8();
129 }
130 end
131}
132
133/// Mark the bare URLs in a run.
134///
135/// CommonMark links `<http://x>` and nothing else, so a URL typed on its own
136/// arrives as text. Marking it here is what lets a reader click it, what lets
137/// [`crate::serialize`] write it back without brackets, and what makes a URL
138/// alone in a block a [`BlockKind::Bookmark`].
139pub(super) fn linkify(text: &mut Text) {
140 let fresh: Vec<Range<usize>> = urls(&text.text)
141 .into_iter()
142 .filter(|range| {
143 // A URL already inside a link, an image target or a code span is
144 // spelled by that mark, not by this one.
145 !text.marks.iter().any(|span| {
146 matches!(
147 span.mark,
148 Mark::Link(_) | Mark::Mention { .. } | Mark::Image(_) | Mark::Code
149 ) && span.range.start < range.end
150 && range.start < span.range.end
151 })
152 })
153 .collect();
154 for range in fresh {
155 let url = text.text[range.clone()].to_string();
156 text.marks.push(MarkSpan {
157 range,
158 mark: Mark::Link(url),
159 });
160 }
161}
162
163/// Drop the whitespace markdown itself drops, and move the marks with it.
164///
165/// Leading and trailing spaces on a line are not content — one trailing space
166/// is insignificant, two are a hard break, and a continuation line's indent
167/// belongs to block structure. Keeping them would mean writing out whitespace
168/// that the next parse discards, so the document would change every time it was
169/// saved. Blank lines at either end of a block go the same way.
170pub(crate) fn normalize(text: &str, marks: &[MarkSpan]) -> Text {
171 let bytes = text.as_bytes();
172 let mut keep = vec![true; text.len()];
173
174 let mut line_begin = 0;
175 for offset in memchr_newlines(text).chain([text.len()]) {
176 let line = &text[line_begin..offset];
177 let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
178 let trail = line.len() - line.trim_end_matches([' ', '\t']).len();
179 keep[line_begin..line_begin + lead].fill(false);
180 keep[offset - trail..offset].fill(false);
181 line_begin = offset + 1;
182 }
183
184 let mut head = 0;
185 while head < text.len() && (!keep[head] || bytes[head] == b'\n') {
186 keep[head] = false;
187 head += 1;
188 }
189 let mut tail = text.len();
190 while tail > 0 && (!keep[tail - 1] || bytes[tail - 1] == b'\n') {
191 keep[tail - 1] = false;
192 tail -= 1;
193 }
194
195 let mut out = String::with_capacity(text.len());
196 let mut map = vec![0; text.len() + 1];
197 for (offset, ch) in text.char_indices() {
198 map[offset] = out.len();
199 if keep[offset] {
200 out.push(ch);
201 }
202 }
203 map[text.len()] = out.len();
204
205 let marks = marks
206 .iter()
207 .map(|span| MarkSpan {
208 range: map[span.range.start]..map[span.range.end],
209 mark: span.mark.clone(),
210 })
211 // A mark left covering nothing has no spelling that survives a
212 // round trip — `****` is literal text, not empty bold. An image is the
213 // exception: `` is exactly a mark over no alt text.
214 .filter(|span| !span.range.is_empty() || matches!(span.mark, Mark::Image(_)))
215 .collect();
216
217 Text {
218 text: out,
219 marks: merge_same_mark(marks),
220 }
221}
222
223/// Fuse spans of the same mark that overlap or nest.
224///
225/// Emphasis inside the same emphasis is redundant — `_a _b_ c_` is italic
226/// either way — and two spans of one mark have no unambiguous spelling: written
227/// back out, the delimiters pair up differently than they came in. Collapsing
228/// them here means the parse produces the one form that survives being written
229/// and read again.
230pub(super) fn merge_same_mark(mut marks: Vec<MarkSpan>) -> Vec<MarkSpan> {
231 let mut ix = 0;
232 while ix < marks.len() {
233 let mut fused = None;
234 for other in ix + 1..marks.len() {
235 let (a, b) = (&marks[ix], &marks[other]);
236 if a.mark == b.mark
237 && !matches!(a.mark, Mark::Image(_) | Mark::Mention { .. })
238 && a.range.start <= b.range.end
239 && b.range.start <= a.range.end
240 {
241 fused = Some((
242 other,
243 a.range.start.min(b.range.start),
244 a.range.end.max(b.range.end),
245 ));
246 break;
247 }
248 }
249 match fused {
250 Some((other, start, end)) => {
251 marks[ix].range = start..end;
252 marks.remove(other);
253 }
254 None => ix += 1,
255 }
256 }
257 marks
258}
259
260/// Flatten a block whose serialized form is one line.
261///
262/// A setext heading (`Title\n=====`) and a table cell can both hold a line
263/// break that has nowhere to go in the output — an ATX `#` heading ends at its
264/// newline, and a second line in a cell would end the row. Both are single-line
265/// blocks in this model, and since a newline and a space are each one byte, the
266/// marks over them do not move.
267pub(crate) fn collapse_to_one_line(text: &mut Text) {
268 if text.text.contains('\n') {
269 text.text = text.text.replace('\n', " ");
270 }
271}
272
273/// Split a trailing `|480` off an image's alt text, which is where a width is
274/// written down.
275///
276/// Obsidian's spelling, and the only one the parser leaves intact: `{width=480}`
277/// trails as literal text and breaks the paragraph out of being an image at all,
278/// and `=480x` is not an image to begin with. The last `|` wins, so a caption
279/// may hold its own — but one *ending* in `|123` gives that tail up, because the
280/// escape that tells them apart on disk is gone by the time this reads it.
281pub(super) fn split_width(alt: &str) -> (&str, Option<u32>) {
282 let Some((caption, tail)) = alt.rsplit_once('|') else {
283 return (alt, None);
284 };
285 // A zero would paint a picture no pixels wide, and nothing that writes one
286 // can produce it — the drag floors at `MIN_IMAGE_WIDTH`.
287 match tail.parse().ok().filter(|width| *width > 0) {
288 Some(width) => (caption, Some(width)),
289 None => (alt, None),
290 }
291}
292
293pub(super) fn memchr_newlines(text: &str) -> impl Iterator<Item = usize> + '_ {
294 text.bytes()
295 .enumerate()
296 .filter_map(|(ix, b)| (b == b'\n').then_some(ix))
297}