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