Skip to main content

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: `![](url)` 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}