GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/doc_editor/preview.rs (39.2K)
1 //! Live preview: how one line of Markdown shows. Pure: text in, styled
2 //! segments out.
3 //!
4 //! Every segment covers a byte range of the line and shows exactly those
5 //! bytes (a tab shows as one space), so caret and click positions map 1:1
6 //! between screen and source. Markup is never replaced, only hidden: on a
7 //! line the caret is NOT on, markers (`**`, `## `, `[[` and a link's target
8 //! part, a list's `- [ ] `…) have no segment and are drawn as nothing — or
9 //! as a decoration the line carries (a bullet, a checkbox, a quote bar, a
10 //! rule). On the caret's line ("active") the markers come back, dimmed,
11 //! as Obsidian's live preview does.
12 //!
13 //! Block context (fenced code, frontmatter) spans lines, so it is computed
14 //! for the whole document first ([`contexts`]); everything else is per line.
15
16 use std::ops::Range;
17
18 /// What a line is inside, from the lines before it.
19 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
20 pub enum Context {
21 Normal,
22 /// Inside a fenced code block (not the fences themselves).
23 Code,
24 /// A ``` or ~~~ fence line, opening or closing.
25 Fence,
26 /// Inside the frontmatter, or one of its `---` lines.
27 Frontmatter,
28 }
29
30 /// A line that is nothing but one embed — `![[pic.png|300]]` or
31 /// `` — as (link text, wanted width, wanted
32 /// height), the size read as Obsidian reads it. `None` for anything else,
33 /// URLs included.
34 pub fn standalone_embed(text: &str) -> Option<(String, Option<u32>, Option<u32>)> {
35 let t = text.trim();
36 let (target, alias) = if let Some(inner) = t.strip_prefix("![[").and_then(|r| r.strip_suffix("]]")) {
37 if inner.contains("]]") || inner.contains("[[") {
38 return None;
39 }
40 match inner.split_once('|') {
41 Some((target, alias)) => (target.to_string(), alias),
42 None => (inner.to_string(), ""),
43 }
44 } else {
45 let rest = t.strip_prefix("?;
47 let dest = dest.strip_suffix(')')?.trim();
48 let dest = dest.strip_prefix('<').and_then(|d| d.strip_suffix('>')).unwrap_or(dest);
49 if dest.contains("://") || dest.contains(')') || alt.contains(']') {
50 return None;
51 }
52 (dest.replace("%20", " "), alt)
53 };
54 let target = target.split('#').next().unwrap_or("").trim().to_string();
55 if target.is_empty() {
56 return None;
57 }
58 let spec = alias.rsplit('|').next().unwrap_or(alias).trim();
59 let num = |v: &str| v.trim().parse::<u32>().ok().filter(|n| *n > 0);
60 let (w, h) = match spec.split_once('x') {
61 Some((w, h)) => match (num(w), num(h)) {
62 (Some(w), Some(h)) => (Some(w), Some(h)),
63 _ => (None, None),
64 },
65 None => (num(spec), None),
66 };
67 Some((target, w, h))
68 }
69
70 /// Every embed in a line, in order — `![[…]]` and `` anywhere in
71 /// it — read as [`standalone_embed`] reads one.
72 pub fn inline_embeds(text: &str) -> Vec<(String, Option<u32>, Option<u32>)> {
73 let mut out = Vec::new();
74 let mut i = 0;
75 while let Some(off) = text[i..].find("![") {
76 let at = i + off;
77 let rest = &text[at..];
78 let end = if rest.starts_with("![[") {
79 rest.find("]]").map(|e| e + 2)
80 } else {
81 rest.find("](").and_then(|m| rest[m..].find(')').map(|e| m + e + 1))
82 };
83 let Some(end) = end else { break };
84 if let Some(found) = standalone_embed(&rest[..end]) {
85 out.push(found);
86 }
87 i = at + end;
88 }
89 out
90 }
91
92 /// Whether an embed alias is Obsidian's size form (`300`, `300x200`).
93 fn embed_size_spec(alias: &str) -> bool {
94 let num = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit());
95 match alias.trim().split_once('x') {
96 Some((w, h)) => num(w.trim()) && num(h.trim()),
97 None => num(alias.trim()),
98 }
99 }
100
101 /// Block context per line: fences toggle code; a `---` first line opens
102 /// frontmatter, closed by the next `---`.
103 pub fn contexts(lines: &[String]) -> Vec<Context> {
104 let mut out = Vec::with_capacity(lines.len());
105 let mut fence: Option<(char, usize)> = None;
106 let mut front = lines.first().is_some_and(|l| l.trim_end() == "---");
107 for (i, line) in lines.iter().enumerate() {
108 if front {
109 out.push(Context::Frontmatter);
110 if i > 0 && line.trim_end() == "---" {
111 front = false;
112 }
113 continue;
114 }
115 let t = line.trim_start();
116 let run = |c: char| t.chars().take_while(|&x| x == c).count();
117 match fence {
118 Some((c, n)) => {
119 if run(c) >= n && t.trim_start_matches(c).trim().is_empty() {
120 fence = None;
121 out.push(Context::Fence);
122 } else {
123 out.push(Context::Code);
124 }
125 }
126 None => {
127 let (bt, tl) = (run('`'), run('~'));
128 if bt >= 3 {
129 fence = Some(('`', bt));
130 out.push(Context::Fence);
131 } else if tl >= 3 {
132 fence = Some(('~', tl));
133 out.push(Context::Fence);
134 } else {
135 out.push(Context::Normal);
136 }
137 }
138 }
139 }
140 out
141 }
142
143 /// A line's block shape.
144 #[derive(Clone, Debug, PartialEq)]
145 pub enum Kind {
146 Plain,
147 Heading(u8),
148 /// `> ` nesting depth.
149 Quote(u8),
150 /// A list item: indentation level, and its marker.
151 List { level: u8, marker: Marker },
152 Rule,
153 Code,
154 Fence,
155 Frontmatter,
156 Table,
157 /// A line of the frontmatter shown as the Properties table.
158 Prop(PropShow),
159 }
160
161 /// How a frontmatter line draws in the Properties table.
162 #[derive(Clone, Debug, PartialEq)]
163 pub enum PropShow {
164 /// The opening `---`: the table's "Properties" header.
165 Header,
166 /// The closing `---`: the rule under the table.
167 Close,
168 /// A row: the key in the key column (a list's key is drawn by its first
169 /// item), the value as segments in the value column; a `true`/`false`
170 /// value is a checkbox instead (its bytes, and whether it is ticked).
171 Row { key: Option<String>, check: Option<(Range<usize>, bool)>, empty: bool },
172 /// Blank, comment, and a list's own key line: no height.
173 Hidden,
174 }
175
176 /// The role of each line of a closed leading frontmatter block, `None`
177 /// outside one (or when it never closes, which shows raw).
178 #[derive(Clone, Debug, PartialEq)]
179 pub enum Prop {
180 Open,
181 Close,
182 /// `key: value` on one line (`value` may be empty).
183 Field { key: Range<usize>, value: Range<usize> },
184 /// `key:` with list items under it; its first item draws the key.
185 ListKey,
186 /// `- item` under a key; the first carries the key line.
187 Item { value: Range<usize>, key_line: Option<usize> },
188 Hidden,
189 /// What the table cannot show (nested maps, block scalars): raw.
190 Raw,
191 }
192
193 #[derive(Clone, Debug, PartialEq)]
194 pub enum Marker {
195 Bullet,
196 /// The number as written, with its `.` or `)`.
197 Number(String),
198 /// A task's status char, and the byte of that char in the line.
199 Task(char, usize),
200 }
201
202 /// A segment's look. `marker` is markup shown on the active line.
203 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
204 pub struct Look {
205 pub bold: bool,
206 pub italic: bool,
207 pub code: bool,
208 pub strike: bool,
209 pub highlight: bool,
210 pub link: bool,
211 pub tag: bool,
212 pub marker: bool,
213 pub comment: bool,
214 /// Code blocks, fences, frontmatter and tables: monospace.
215 pub mono: bool,
216 /// The dim text of a done task or a fence line.
217 pub dim: bool,
218 /// An item of a list property, drawn as a pill.
219 pub pill: bool,
220 }
221
222 #[derive(Clone, Debug, PartialEq)]
223 pub enum Target {
224 /// `[[target#sub|…]]` or a relative `[…](path)`.
225 Note { target: String, subpath: Option<String> },
226 Url(String),
227 }
228
229 #[derive(Clone, Debug, PartialEq)]
230 pub struct Seg {
231 pub range: Range<usize>,
232 pub look: Look,
233 /// The link this segment shows, by index into [`Line::links`].
234 pub link: Option<usize>,
235 }
236
237 #[derive(Clone, Debug, PartialEq)]
238 pub struct Line {
239 pub kind: Kind,
240 pub segs: Vec<Seg>,
241 pub links: Vec<Target>,
242 /// Where content starts after the hidden prefix (`## `, `> `, a list
243 /// marker), for a click left of the text and an empty item's caret.
244 pub content_start: usize,
245 }
246
247 const BOLD: u16 = 1;
248 const ITALIC: u16 = 2;
249 const CODE: u16 = 4;
250 const STRIKE: u16 = 8;
251 const HIGHLIGHT: u16 = 16;
252 const LINK: u16 = 32;
253 const TAG: u16 = 64;
254 const MARKER: u16 = 128;
255 const COMMENT: u16 = 256;
256 const DIM: u16 = 512;
257
258 /// The fenced code block holding `line` — its opening and closing fence
259 /// lines (the last line when it never closes) — or `None` outside one.
260 pub fn fenced_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
261 if !matches!(ctx.get(line), Some(Context::Code | Context::Fence)) {
262 return None;
263 }
264 let mut open: Option<usize> = None;
265 for (i, c) in ctx.iter().enumerate() {
266 match (c, open) {
267 (Context::Fence, None) => open = Some(i),
268 (Context::Fence, Some(a)) => {
269 if line >= a && line <= i {
270 return Some((a, i));
271 }
272 open = None;
273 }
274 _ => {}
275 }
276 if i > line && open.is_none() {
277 break;
278 }
279 }
280 open.map(|a| (a, ctx.len() - 1))
281 }
282
283 /// The leading frontmatter block (open and close lines) when `line` is
284 /// in it.
285 pub fn frontmatter_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
286 if ctx.get(line) != Some(&Context::Frontmatter) {
287 return None;
288 }
289 let end = ctx.iter().take_while(|c| **c == Context::Frontmatter).count();
290 Some((0, end - 1))
291 }
292
293 /// A `key:` at the start of `line`: the key's byte range and where what
294 /// follows the colon starts.
295 fn yaml_key(line: &str) -> Option<(Range<usize>, usize)> {
296 if line.starts_with([' ', '\t', '-', '#']) {
297 return None;
298 }
299 let b = line.as_bytes();
300 let colon = (0..b.len()).find(|&i| b[i] == b':' && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t'))?;
301 let key = line[..colon].trim_end();
302 (!key.is_empty()).then_some((0..key.len(), colon + 1))
303 }
304
305 /// The range of `line[from..]` without surrounding whitespace.
306 fn trimmed(line: &str, from: usize) -> Range<usize> {
307 let rest = &line[from..];
308 let start = from + (rest.len() - rest.trim_start().len());
309 start..from + rest.trim_end().len()
310 }
311
312 /// Roles for the frontmatter lines (see [`Prop`]), per line of the
313 /// document; `None` past the block.
314 pub fn properties(lines: &[String], ctx: &[Context]) -> Vec<Option<Prop>> {
315 let mut out = vec![None; lines.len()];
316 let Some((_, end)) = frontmatter_block(ctx, 0) else { return out };
317 if end == 0 || lines[end].trim_end() != "---" {
318 return out; // never closed: raw
319 }
320 out[0] = Some(Prop::Open);
321 out[end] = Some(Prop::Close);
322 let mut list_key: Option<usize> = None;
323 let mut raw_block = false;
324 for i in 1..end {
325 let line = &lines[i];
326 let t = line.trim_start();
327 let indented = t.len() < line.len();
328 let role = if t.is_empty() || t.starts_with('#') {
329 Prop::Hidden
330 } else if raw_block && indented {
331 Prop::Raw
332 } else if let Some((key, after)) = yaml_key(line) {
333 raw_block = false;
334 let value = trimmed(line, after);
335 let v = &line[value.clone()];
336 if v.starts_with(['|', '>']) {
337 raw_block = true;
338 Prop::Raw
339 } else if v.is_empty() {
340 // A list follows, or a nested map (raw), or nothing.
341 let next = lines[i + 1..end].iter().find(|l| !l.trim().is_empty());
342 match next.map(|l| l.trim_start()) {
343 Some(n) if n == "-" || n.starts_with("- ") => {
344 list_key = Some(i);
345 Prop::ListKey
346 }
347 Some(_) if next.is_some_and(|l| l.starts_with([' ', '\t'])) => {
348 raw_block = true;
349 Prop::Field { key, value }
350 }
351 _ => Prop::Field { key, value },
352 }
353 } else {
354 Prop::Field { key, value }
355 }
356 } else if t == "-" || t.starts_with("- ") {
357 let value = trimmed(line, line.len() - t.len() + 1);
358 Prop::Item { value, key_line: list_key.take() }
359 } else {
360 Prop::Raw
361 };
362 out[i] = Some(role);
363 }
364 out
365 }
366
367 /// The key of a `key:` line, for a list item drawing its key.
368 pub fn prop_key(line: &str) -> Option<&str> {
369 yaml_key(line).map(|(k, _)| &line[k])
370 }
371
372 /// A quoted YAML scalar's inside.
373 fn unquote(line: &str, r: Range<usize>) -> Range<usize> {
374 let v = &line[r.clone()];
375 if v.len() >= 2 && ((v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\''))) {
376 r.start + 1..r.end - 1
377 } else {
378 r
379 }
380 }
381
382 /// The items of an inline `[a, "b", [[c]]]` list: split on commas outside
383 /// quotes and `[[…]]`, trimmed and unquoted.
384 fn flow_items(line: &str, r: Range<usize>) -> Vec<Range<usize>> {
385 let inner = r.start + 1..r.end - 1;
386 let b = line.as_bytes();
387 let (mut out, mut start, mut quote, mut depth) = (Vec::new(), inner.start, None::<u8>, 0i32);
388 let mut i = inner.start;
389 while i <= inner.end {
390 let c = if i < inner.end { b[i] } else { b',' };
391 match (quote, c) {
392 (Some(q), _) if c == q => quote = None,
393 (Some(_), _) => {}
394 (None, b'"' | b'\'') => quote = Some(c),
395 (None, b'[') => depth += 1,
396 (None, b']') => depth -= 1,
397 (None, b',') if depth <= 0 => {
398 let item = unquote(line, trimmed(&line[..i], start));
399 if !item.is_empty() {
400 out.push(item);
401 }
402 start = i + 1;
403 }
404 _ => {}
405 }
406 i += 1;
407 }
408 out
409 }
410
411 /// Inline-style `line[r]` as part of a property value, segments offset into
412 /// the line; `pill` marks them as list pills.
413 fn value_segs(line: &str, r: Range<usize>, pill: bool, segs: &mut Vec<Seg>, links: &mut Vec<Target>) {
414 let sub = &line[r.clone()];
415 let styled = style_line(sub, Context::Normal, false);
416 let base = links.len();
417 if styled.kind != Kind::Plain || styled.content_start != 0 {
418 segs.push(Seg { range: r, look: Look { pill, ..Look::default() }, link: None });
419 return;
420 }
421 for s in styled.segs {
422 let look = Look { pill, ..s.look };
423 segs.push(Seg { range: s.range.start + r.start..s.range.end + r.start, look, link: s.link.map(|k| k + base) });
424 }
425 links.extend(styled.links);
426 }
427
428 /// Style a frontmatter line as its row of the Properties table. `key` is
429 /// the key an [`Prop::Item`] draws (its key line's), if it is the first.
430 pub fn style_property(line: &str, prop: &Prop, key: Option<&str>) -> Line {
431 let mut segs = Vec::new();
432 let mut links = Vec::new();
433 let row = |key: Option<String>, check, empty| Kind::Prop(PropShow::Row { key, check, empty });
434 let (kind, content_start) = match prop {
435 Prop::Open => (Kind::Prop(PropShow::Header), 0),
436 Prop::Close => (Kind::Prop(PropShow::Close), 0),
437 Prop::Hidden | Prop::ListKey => (Kind::Prop(PropShow::Hidden), 0),
438 Prop::Raw => return style_line(line, Context::Frontmatter, false),
439 Prop::Field { key: k, value } => {
440 let v = &line[value.clone()];
441 let key = Some(line[k.clone()].to_string());
442 if v == "true" || v == "false" {
443 (row(key, Some((value.clone(), v == "true")), false), value.start)
444 } else if v.starts_with('[') && v.ends_with(']') && !v.starts_with("[[") {
445 let items = flow_items(line, value.clone());
446 // The separators hide; the layout pads pills apart.
447 for it in &items {
448 value_segs(line, it.clone(), true, &mut segs, &mut links);
449 }
450 (row(key, None, items.is_empty()), value.start)
451 } else {
452 if !value.is_empty() {
453 value_segs(line, unquote(line, value.clone()), false, &mut segs, &mut links);
454 }
455 (row(key, None, value.is_empty()), value.start)
456 }
457 }
458 Prop::Item { value, .. } => {
459 let key = key.map(str::to_string);
460 let it = unquote(line, value.clone());
461 if !it.is_empty() {
462 value_segs(line, it, true, &mut segs, &mut links);
463 }
464 (row(key, None, value.is_empty()), value.start)
465 }
466 };
467 Line { kind, segs, links, content_start }
468 }
469
470 /// Style one line. `active` shows its markers; `ctx` is its block context.
471 pub fn style_line(text: &str, ctx: Context, active: bool) -> Line {
472 let n = text.len();
473 let whole = |look: Look| Line { kind: Kind::Plain, segs: vec![Seg { range: 0..n, look, link: None }], links: Vec::new(), content_start: 0 };
474 match ctx {
475 Context::Code => return Line { kind: Kind::Code, ..whole(Look { mono: true, ..Look::default() }) },
476 // A fence hides while the caret is outside its block (the editor
477 // makes the whole block active), as Obsidian's live preview does.
478 Context::Fence if !active => return Line { kind: Kind::Fence, segs: Vec::new(), links: Vec::new(), content_start: 0 },
479 Context::Fence => return Line { kind: Kind::Fence, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
480 Context::Frontmatter => return Line { kind: Kind::Frontmatter, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
481 Context::Normal => {}
482 }
483 let mut flags = vec![0u16; n];
484 let mut link_of: Vec<Option<usize>> = vec![None; n];
485 let mut links: Vec<Target> = Vec::new();
486 let mut kind = Kind::Plain;
487 let mut prefix = 0usize;
488
489 let trimmed = text.trim_start();
490 let indent_bytes = n - trimmed.len();
491 // A thematic break: three or more of - * _ and nothing else.
492 let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
493 if compact.len() >= 3 && (compact.chars().all(|c| c == '-') || compact.chars().all(|c| c == '*') || compact.chars().all(|c| c == '_')) && indent_bytes < 4 {
494 let look = Look { marker: true, ..Look::default() };
495 return Line { kind: Kind::Rule, segs: if active { vec![Seg { range: 0..n, look, link: None }] } else { Vec::new() }, links, content_start: n };
496 }
497 if trimmed.starts_with('|') {
498 return Line { kind: Kind::Table, ..whole(Look { mono: true, ..Look::default() }) };
499 }
500 // Headings.
501 let hashes = trimmed.chars().take_while(|&c| c == '#').count();
502 if (1..=6).contains(&hashes) && indent_bytes < 4 && (trimmed.len() == hashes || trimmed[hashes..].starts_with(' ')) {
503 kind = Kind::Heading(hashes as u8);
504 prefix = (indent_bytes + hashes + 1).min(n);
505 } else {
506 // Quotes, possibly nested, then a list marker inside them.
507 let mut at = 0usize;
508 let mut depth = 0u8;
509 loop {
510 let rest = &text[at..];
511 let sp = rest.len() - rest.trim_start().len();
512 if rest[sp..].starts_with('>') {
513 depth += 1;
514 at += sp + 1;
515 if text[at..].starts_with(' ') {
516 at += 1;
517 }
518 } else {
519 break;
520 }
521 }
522 if depth > 0 {
523 kind = Kind::Quote(depth);
524 prefix = at;
525 }
526 if let Some((level, marker, end)) = list_marker(&text[at..]) {
527 let marker = match marker {
528 Marker::Task(c, b) => Marker::Task(c, b + at),
529 m => m,
530 };
531 if depth == 0 {
532 kind = Kind::List { level, marker };
533 }
534 prefix = at + end;
535 }
536 }
537 for f in flags.iter_mut().take(prefix) {
538 *f |= MARKER;
539 }
540 let done = matches!(kind, Kind::List { marker: Marker::Task(c, _), .. } if c != ' ');
541 inline(text, prefix, &mut flags, &mut link_of, &mut links);
542 if done {
543 for f in flags.iter_mut().skip(prefix) {
544 *f |= DIM;
545 }
546 }
547 // Merge bytes into segments at char boundaries.
548 let mut segs: Vec<Seg> = Vec::new();
549 for (i, _) in text.char_indices() {
550 let f = flags[i];
551 let hidden = !active && f & MARKER != 0;
552 if hidden {
553 continue;
554 }
555 let end = i + text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
556 let look = Look {
557 bold: f & BOLD != 0,
558 italic: f & ITALIC != 0,
559 code: f & CODE != 0,
560 strike: f & STRIKE != 0,
561 highlight: f & HIGHLIGHT != 0,
562 link: f & LINK != 0,
563 tag: f & TAG != 0,
564 marker: f & MARKER != 0,
565 comment: f & COMMENT != 0,
566 mono: false,
567 dim: f & DIM != 0,
568 pill: false,
569 };
570 let link = link_of[i];
571 match segs.last_mut() {
572 Some(s) if s.range.end == i && s.look == look && s.link == link => s.range.end = end,
573 _ => segs.push(Seg { range: i..end, look, link }),
574 }
575 }
576 Line { kind, segs, links, content_start: prefix }
577 }
578
579 /// A list marker at the start of `s` (after indentation): its level, the
580 /// marker, and the byte where the content starts.
581 fn list_marker(s: &str) -> Option<(u8, Marker, usize)> {
582 let body = s.trim_start_matches([' ', '\t']);
583 let ind = &s[..s.len() - body.len()];
584 // A tab is one level (Obsidian indents with tabs); two spaces are one.
585 let cols: usize = ind.chars().map(|c| if c == '\t' { 2 } else { 1 }).sum();
586 let level = (cols / 2).min(12) as u8;
587 let (marker, mlen) = if body.starts_with("- ") || body.starts_with("* ") || body.starts_with("+ ") {
588 (Marker::Bullet, 2)
589 } else if matches!(body, "-" | "*" | "+") {
590 (Marker::Bullet, 1)
591 } else {
592 let digits = body.chars().take_while(char::is_ascii_digit).count();
593 if digits == 0 || digits > 9 {
594 return None;
595 }
596 let after = &body[digits..];
597 if after.starts_with(". ") || after.starts_with(") ") {
598 (Marker::Number(body[..digits + 1].to_string()), digits + 2)
599 } else {
600 return None;
601 }
602 };
603 let mut end = ind.len() + mlen;
604 let rest = &s[end..];
605 let mut chars = rest.chars();
606 if let (Some('['), Some(c), Some(']')) = (chars.next(), chars.next(), chars.next()) {
607 let after = &rest[2 + c.len_utf8()..];
608 if after.is_empty() || after.starts_with(' ') {
609 let status_at = end + 1;
610 end += 2 + c.len_utf8() + usize::from(after.starts_with(' '));
611 return Some((level, Marker::Task(c, status_at), end));
612 }
613 }
614 Some((level, marker, end))
615 }
616
617 /// Mark the inline syntax of `text[from..]` in `flags`.
618 fn inline(text: &str, from: usize, flags: &mut [u16], link_of: &mut [Option<usize>], links: &mut Vec<Target>) {
619 let b = text.as_bytes();
620 let n = b.len();
621 let mut taken = vec![false; n];
622 let mark = |flags: &mut [u16], r: Range<usize>, f: u16| {
623 for x in &mut flags[r] {
624 *x |= f;
625 }
626 };
627 // Code spans first: nothing inside them is syntax.
628 let mut i = from;
629 while i < n {
630 if b[i] == b'`' {
631 let run = b[i..].iter().take_while(|&&c| c == b'`').count();
632 let close = find_run(b, i + run, b'`', run);
633 if let Some(c) = close {
634 mark(flags, i..i + run, MARKER | CODE);
635 mark(flags, i + run..c, CODE);
636 mark(flags, c..c + run, MARKER | CODE);
637 for t in &mut taken[i..c + run] {
638 *t = true;
639 }
640 i = c + run;
641 continue;
642 }
643 i += run;
644 continue;
645 }
646 i += 1;
647 }
648 // Comments.
649 let mut i = from;
650 while let Some(o) = find_free(text, i, "%%", &taken) {
651 let Some(c) = find_free(text, o + 2, "%%", &taken) else { break };
652 mark(flags, o..c + 2, COMMENT);
653 mark(flags, o..o + 2, MARKER);
654 mark(flags, c..c + 2, MARKER);
655 for t in &mut taken[o..c + 2] {
656 *t = true;
657 }
658 i = c + 2;
659 }
660 // Wikilinks and embeds: [[target#sub|alias]].
661 let mut i = from;
662 while let Some(o) = find_free(text, i, "[[", &taken) {
663 let Some(c) = find_free(text, o + 2, "]]", &taken) else { break };
664 let inner = &text[o + 2..c];
665 let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
666 let (target, alias_at) = match inner.find('|') {
667 Some(p) => (&inner[..p], Some(o + 2 + p + 1)),
668 None => (inner, None),
669 };
670 let (t, sub) = match target.split_once('#') {
671 Some((t, s)) => (t.to_string(), Some(s.to_string())),
672 None => (target.to_string(), None),
673 };
674 let idx = links.len();
675 links.push(Target::Note { target: t.trim().to_string(), subpath: sub.filter(|s| !s.is_empty()) });
676 // An embed's alias that is only a size (`![[pic.png|300]]`) is not
677 // a caption: show the file name and hide the `|300`, as for links.
678 let size_alias = start < o && alias_at.is_some_and(|a| embed_size_spec(&text[a..c]));
679 let shown = match alias_at {
680 Some(a) if size_alias => o + 2..a - 1,
681 Some(a) => a..c,
682 None => o + 2..c,
683 };
684 mark(flags, start..shown.start, MARKER);
685 mark(flags, shown.clone(), LINK);
686 mark(flags, shown.end..c + 2, MARKER);
687 for x in &mut link_of[shown] {
688 *x = Some(idx);
689 }
690 for t in &mut taken[start..c + 2] {
691 *t = true;
692 }
693 i = c + 2;
694 }
695 // Markdown links: [text](dest).
696 let mut i = from;
697 while let Some(o) = find_free(text, i, "[", &taken) {
698 let Some(c) = find_free(text, o + 1, "](", &taken) else { break };
699 let Some(e) = find_free(text, c + 2, ")", &taken) else { break };
700 let dest = text[c + 2..e].trim();
701 let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
702 let idx = links.len();
703 let decoded = dest.replace("%20", " ");
704 links.push(if dest.contains("://") || dest.starts_with("mailto:") {
705 Target::Url(dest.to_string())
706 } else {
707 let (t, s) = match decoded.split_once('#') {
708 Some((t, s)) => (t.to_string(), Some(s.to_string())),
709 None => (decoded.clone(), None),
710 };
711 Target::Note { target: t, subpath: s }
712 });
713 mark(flags, start..o + 1, MARKER);
714 mark(flags, o + 1..c, LINK);
715 mark(flags, c..e + 1, MARKER);
716 for x in &mut link_of[o + 1..c] {
717 *x = Some(idx);
718 }
719 for t in &mut taken[start..e + 1] {
720 *t = true;
721 }
722 i = e + 1;
723 }
724 // Emphasis: paired delimiters, longest first.
725 for (delim, flag) in [("**", BOLD), ("__", BOLD), ("~~", STRIKE), ("==", HIGHLIGHT), ("*", ITALIC), ("_", ITALIC)] {
726 let mut i = from;
727 while let Some(o) = find_free(text, i, delim, &taken) {
728 let inner = o + delim.len();
729 // Opening: not followed by a space; `_` must not sit mid-word.
730 let next = text[inner..].chars().next();
731 let prev = text[..o].chars().next_back();
732 let mid_word = delim.starts_with('_') && prev.is_some_and(char::is_alphanumeric);
733 if next.is_none_or(char::is_whitespace) || mid_word {
734 i = inner;
735 continue;
736 }
737 let mut search = inner;
738 let close = loop {
739 let Some(c) = find_free(text, search, delim, &taken) else { break None };
740 let before = text[..c].chars().next_back();
741 let after = text[c + delim.len()..].chars().next();
742 let bad_word = delim.starts_with('_') && after.is_some_and(char::is_alphanumeric);
743 if c > inner && before.is_some_and(|ch| !ch.is_whitespace()) && !bad_word {
744 break Some(c);
745 }
746 search = c + delim.len();
747 };
748 let Some(c) = close else {
749 i = inner;
750 continue;
751 };
752 mark(flags, o..inner, MARKER);
753 mark(flags, inner..c, flag);
754 mark(flags, c..c + delim.len(), MARKER);
755 for t in &mut taken[o..inner] {
756 *t = true;
757 }
758 for t in &mut taken[c..c + delim.len()] {
759 *t = true;
760 }
761 i = c + delim.len();
762 }
763 }
764 // Tags: #name at a word start, not all digits.
765 let mut i = from;
766 while i < n {
767 if b[i] == b'#' && !taken[i] && (i == 0 || text[..i].chars().next_back().is_some_and(char::is_whitespace)) {
768 let rest = &text[i + 1..];
769 let len: usize = rest.chars().take_while(|&c| c.is_alphanumeric() || "_-/".contains(c)).map(char::len_utf8).sum();
770 if len > 0 && !rest[..len].chars().all(|c| c.is_ascii_digit() || c == '/') {
771 mark(flags, i..i + 1 + len, TAG);
772 i += 1 + len;
773 continue;
774 }
775 }
776 i += 1;
777 }
778 }
779
780 /// The next `pat` at or after `from` that starts on a byte nothing else
781 /// has taken.
782 fn find_free(text: &str, from: usize, pat: &str, taken: &[bool]) -> Option<usize> {
783 let mut at = from;
784 while at <= text.len() {
785 let p = at + text.get(at..)?.find(pat)?;
786 if !taken[p..p + pat.len()].iter().any(|&t| t) {
787 return Some(p);
788 }
789 at = p + 1;
790 while at < text.len() && !text.is_char_boundary(at) {
791 at += 1;
792 }
793 }
794 None
795 }
796
797 /// The next run of exactly `len` `c` bytes at or after `from`.
798 fn find_run(b: &[u8], from: usize, c: u8, len: usize) -> Option<usize> {
799 let mut i = from;
800 while i < b.len() {
801 if b[i] == c {
802 let run = b[i..].iter().take_while(|&&x| x == c).count();
803 if run == len {
804 return Some(i);
805 }
806 i += run;
807 } else {
808 i += 1;
809 }
810 }
811 None
812 }
813
814 #[cfg(test)]
815 mod tests {
816 use super::*;
817
818 #[test]
819 fn a_size_alias_shows_the_file_name() {
820 let shown = |t: &str| {
821 let l = style_line(t, Context::Normal, false);
822 l.segs.iter().filter(|g| g.look.link).map(|g| &t[g.range.clone()]).collect::<String>()
823 };
824 assert_eq!(shown("![[pic.png|300]]"), "pic.png");
825 assert_eq!(shown("![[pic.png|300x20]]"), "pic.png");
826 assert_eq!(shown("![[pic.png|a caption]]"), "a caption");
827 assert_eq!(shown("[[Note|300]]"), "300");
828 }
829
830 #[test]
831 fn inline_embeds_in_a_line() {
832 let found = inline_embeds("see ![[a.png|40]] and  then ![[N]] end");
833 assert_eq!(found, vec![
834 ("a.png".to_string(), Some(40), None),
835 ("b c.png".to_string(), None, None),
836 ("N".to_string(), None, None),
837 ]);
838 assert!(inline_embeds("no embeds ![ here").is_empty());
839 }
840
841 #[test]
842 fn standalone_embeds() {
843 assert_eq!(standalone_embed("![[a.png]]"), Some(("a.png".into(), None, None)));
844 assert_eq!(standalone_embed(" ![[Pics/a.png|300]] "), Some(("Pics/a.png".into(), Some(300), None)));
845 assert_eq!(standalone_embed("![[a.png|300x200]]"), Some(("a.png".into(), Some(300), Some(200))));
846 assert_eq!(standalone_embed(""), Some(("my pic.png".into(), Some(120), None)));
847 assert_eq!(standalone_embed(""), None);
848 assert_eq!(standalone_embed("see ![[a.png]]"), None);
849 assert_eq!(standalone_embed("![[a.png]] and ![[b.png]]"), None);
850 }
851
852 /// The shown text of each segment, `[marker]` for dimmed markup.
853 fn shown(text: &str, line: &Line) -> Vec<String> {
854 line.segs
855 .iter()
856 .map(|s| {
857 let t = &text[s.range.clone()];
858 if s.look.marker { format!("[{t}]") } else { t.to_string() }
859 })
860 .collect()
861 }
862
863 #[test]
864 fn inactive_lines_hide_markup() {
865 let t = "## A **bold** and *it* with `code`";
866 let l = style_line(t, Context::Normal, false);
867 assert_eq!(l.kind, Kind::Heading(2));
868 assert_eq!(shown(t, &l), ["A ", "bold", " and ", "it", " with ", "code"]);
869 assert!(l.segs[1].look.bold && l.segs[3].look.italic && l.segs[5].look.code);
870 assert_eq!(l.content_start, 3);
871 }
872
873 #[test]
874 fn the_active_line_shows_markup_dimmed() {
875 let t = "## A **bold**";
876 let l = style_line(t, Context::Normal, true);
877 assert_eq!(shown(t, &l), ["[## ]", "A ", "[**]", "bold", "[**]"]);
878 assert!(l.segs[2].look.marker && l.segs[3].look.bold && !l.segs[3].look.marker);
879 // Every byte is covered, in order: 1:1 with the source.
880 let mut at = 0;
881 for s in &l.segs {
882 assert_eq!(s.range.start, at);
883 at = s.range.end;
884 }
885 assert_eq!(at, t.len());
886 }
887
888 #[test]
889 fn links_show_their_alias_and_carry_a_target() {
890 let t = "see [[Note#Part|the part]] and [web](https://a.b) or [[Plain]]";
891 let l = style_line(t, Context::Normal, false);
892 assert_eq!(shown(t, &l), ["see ", "the part", " and ", "web", " or ", "Plain"]);
893 assert_eq!(l.links[0], Target::Note { target: "Note".into(), subpath: Some("Part".into()) });
894 assert_eq!(l.links[1], Target::Note { target: "Plain".into(), subpath: None });
895 assert_eq!(l.links[2], Target::Url("https://a.b".into()));
896 assert_eq!(l.segs[1].link, Some(0));
897 assert_eq!(l.segs[3].link, Some(2));
898 }
899
900 #[test]
901 fn frontmatter_lines_get_property_roles() {
902 let src = "---\ntitle: \"Hello\"\ntags: [a, \"b c\"]\ndone: true\n\naliases:\n - one\n - \"[[Two]]\"\nmeta:\n nested: x\nempty:\n---\nbody";
903 let lines: Vec<String> = src.split('\n').map(String::from).collect();
904 let ctx = contexts(&lines);
905 let p = properties(&lines, &ctx);
906 assert_eq!(p[0], Some(Prop::Open));
907 assert_eq!(p[11], Some(Prop::Close));
908 assert_eq!(p[12], None);
909 assert!(matches!(p[1], Some(Prop::Field { .. })));
910 assert_eq!(p[4], Some(Prop::Hidden));
911 assert_eq!(p[5], Some(Prop::ListKey));
912 assert!(matches!(p[6], Some(Prop::Item { key_line: Some(5), .. })));
913 assert!(matches!(p[7], Some(Prop::Item { key_line: None, .. })));
914 assert_eq!(p[9], Some(Prop::Raw), "a nested map shows raw");
915 assert_eq!(prop_key(&lines[5]), Some("aliases"));
916
917 let shown = |i: usize, key: Option<&str>| {
918 let l = style_property(&lines[i], p[i].as_ref().unwrap(), key);
919 (l.kind.clone(), l.segs.iter().map(|s| lines[i][s.range.clone()].to_string()).collect::<Vec<_>>(), l)
920 };
921 let (kind, segs, _) = shown(1, None);
922 assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("title".into()), check: None, empty: false }));
923 assert_eq!(segs, ["Hello"], "quotes hide");
924 let (_, segs, l) = shown(2, None);
925 assert_eq!(segs, ["a", "b c"]);
926 assert!(l.segs.iter().all(|s| s.look.pill));
927 let (kind, segs, _) = shown(3, None);
928 assert!(segs.is_empty());
929 assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("done".into()), check: Some((6..10, true)), empty: false }));
930 let (kind, segs, _) = shown(6, Some("aliases"));
931 assert_eq!(segs, ["one"]);
932 assert!(matches!(kind, Kind::Prop(PropShow::Row { key: Some(k), .. }) if k == "aliases"));
933 let (_, segs, l) = shown(7, None);
934 assert_eq!(segs, ["Two"], "a link item shows its name");
935 assert_eq!(l.links, [Target::Note { target: "Two".into(), subpath: None }]);
936 let (kind, _, _) = shown(10, None);
937 assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("empty".into()), check: None, empty: true }));
938
939 // Never closed: no table.
940 let open: Vec<String> = ["---", "a: b"].iter().map(|s| s.to_string()).collect();
941 assert!(properties(&open, &contexts(&open)).iter().all(Option::is_none));
942 }
943
944 #[test]
945 fn fenced_blocks_are_found_and_fences_hide() {
946 let lines: Vec<String> = ["a", "```", "x", "```", "```", "y"].iter().map(|s| s.to_string()).collect();
947 let ctx = contexts(&lines);
948 assert_eq!(fenced_block(&ctx, 0), None);
949 assert_eq!(fenced_block(&ctx, 2), Some((1, 3)));
950 assert_eq!(fenced_block(&ctx, 3), Some((1, 3)));
951 assert_eq!(fenced_block(&ctx, 4), Some((4, 5)));
952 assert!(style_line("```rust", Context::Fence, false).segs.is_empty());
953 assert_eq!(style_line("```rust", Context::Fence, true).segs.len(), 1);
954 }
955
956 #[test]
957 fn lists_tasks_quotes_and_rules() {
958 let t = "\t- [x] done *thing*";
959 let l = style_line(t, Context::Normal, false);
960 assert_eq!(l.kind, Kind::List { level: 1, marker: Marker::Task('x', 4) });
961 assert_eq!(shown(t, &l), ["done ", "thing"]);
962 assert!(l.segs[0].look.dim);
963 let l = style_line("1. first", Context::Normal, false);
964 assert_eq!(l.kind, Kind::List { level: 0, marker: Marker::Number("1.".into()) });
965 let t = "> > quoted";
966 let l = style_line(t, Context::Normal, false);
967 assert_eq!((l.kind.clone(), shown(t, &l)), (Kind::Quote(2), vec!["quoted".to_string()]));
968 assert_eq!(style_line("---", Context::Normal, false).kind, Kind::Rule);
969 assert!(style_line("---", Context::Normal, false).segs.is_empty());
970 assert_eq!(style_line("- ", Context::Normal, false).kind, Kind::List { level: 0, marker: Marker::Bullet });
971 }
972
973 #[test]
974 fn emphasis_needs_real_pairs() {
975 let t = "a * b * c snake_case_word 2*3*4 ==hi== ~~no~~";
976 let l = style_line(t, Context::Normal, false);
977 let styled: Vec<(String, bool, bool, bool)> = l
978 .segs
979 .iter()
980 .map(|s| (t[s.range.clone()].to_string(), s.look.italic, s.look.highlight, s.look.strike))
981 .collect();
982 assert!(styled.iter().any(|(s, _, h, _)| s == "hi" && *h));
983 assert!(styled.iter().any(|(s, _, _, k)| s == "no" && *k));
984 assert!(styled.iter().all(|(s, i, _, _)| !(s.contains("snake") && *i)), "{styled:?}");
985 assert!(!styled.iter().any(|(s, i, _, _)| s.contains(" b ") && *i), "spaced * is not emphasis");
986 }
987
988 #[test]
989 fn tags_and_code_protect() {
990 let t = "#tag and `#not **x**` #2026 #a/b";
991 let l = style_line(t, Context::Normal, false);
992 let tags: Vec<&str> = l.segs.iter().filter(|s| s.look.tag).map(|s| &t[s.range.clone()]).collect();
993 assert_eq!(tags, ["#tag", "#a/b"]);
994 assert!(l.segs.iter().any(|s| s.look.code && &t[s.range.clone()] == "#not **x**"));
995 }
996
997 #[test]
998 fn block_contexts() {
999 let lines: Vec<String> = ["---", "a: 1", "---", "text", "```rust", "let x;", "```", "after"].iter().map(|s| s.to_string()).collect();
1000 assert_eq!(
1001 contexts(&lines),
1002 [
1003 Context::Frontmatter,
1004 Context::Frontmatter,
1005 Context::Frontmatter,
1006 Context::Normal,
1007 Context::Fence,
1008 Context::Code,
1009 Context::Fence,
1010 Context::Normal
1011 ]
1012 );
1013 assert!(style_line("let x;", Context::Code, false).segs[0].look.mono);
1014 }
1015 }