1use nova_diagnostics::{FileId, Spanned};
6use pulldown_cmark::{html, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::escape;
9use crate::highlight::highlight;
10
11fn options() -> Options {
14 let mut options = Options::empty();
15 options.insert(Options::ENABLE_TABLES);
16 options.insert(Options::ENABLE_STRIKETHROUGH);
17 options
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Block {
23 Test {
26 unknown: Vec<String>,
28 },
29 Ignored,
32 Other,
34}
35
36impl Block {
37 pub fn is_nova(&self) -> bool {
39 !matches!(self, Block::Other)
40 }
41}
42
43pub fn classify(info: Option<&str>) -> Block {
47 let Some(info) = info else {
48 return Block::Test {
49 unknown: Vec::new(),
50 };
51 };
52 let mut words = info
53 .split(|c: char| c == ',' || c.is_whitespace())
54 .filter(|w| !w.is_empty());
55 match words.next() {
56 None => Block::Test {
57 unknown: Vec::new(),
58 },
59 Some("ignore") => Block::Ignored,
60 Some("nova") => {
61 let rest: Vec<&str> = words.collect();
62 if rest.contains(&"ignore") {
63 Block::Ignored
64 } else {
65 Block::Test {
66 unknown: rest.iter().map(|w| w.to_string()).collect(),
67 }
68 }
69 }
70 Some(_) => Block::Other,
71 }
72}
73
74pub fn hidden(line: &str) -> Option<(usize, usize)> {
78 let rest = line.trim_start_matches([' ', '\t']);
79 let at = line.len() - rest.len();
80 if rest.starts_with("# ") {
81 Some((at, 2))
82 } else if rest == "#" {
83 Some((at, 1))
84 } else {
85 None
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CodeLine {
92 pub text: String,
94 pub pad: usize,
97 pub at: usize,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CodeBlock {
104 pub block: Block,
106 pub start: usize,
109 pub lines: Vec<CodeLine>,
111}
112
113pub fn code_blocks(text: &str) -> Vec<CodeBlock> {
115 let mut out: Vec<CodeBlock> = Vec::new();
116 let mut open: Option<CodeBlock> = None;
117 let mut line: Option<CodeLine> = None;
118 for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
119 match event {
120 Event::Start(Tag::CodeBlock(kind)) => {
121 let block = match &kind {
122 CodeBlockKind::Indented => classify(None),
123 CodeBlockKind::Fenced(info) => classify(Some(info)),
124 };
125 open = Some(CodeBlock {
126 block,
127 start: range.start,
128 lines: Vec::new(),
129 });
130 }
131 Event::Text(t) => {
132 let Some(b) = open.as_mut() else {
133 continue;
134 };
135 if range.is_empty() {
136 let l = line.get_or_insert_with(|| CodeLine {
138 text: String::new(),
139 pad: 0,
140 at: range.start,
141 });
142 l.text.push_str(&t);
143 l.pad += t.len();
144 l.at = range.start;
145 continue;
146 }
147 let mut at = range.start;
150 for piece in t.split_inclusive('\n') {
151 let (body, ends) = match piece.strip_suffix('\n') {
152 Some(body) => (body, true),
153 None => (piece, false),
154 };
155 let l = line.get_or_insert_with(|| CodeLine {
156 text: String::new(),
157 pad: 0,
158 at,
159 });
160 if l.text.len() == l.pad {
161 l.at = at;
162 }
163 l.text.push_str(body);
164 if ends {
165 b.lines.extend(line.take());
166 }
167 at += piece.len();
168 }
169 }
170 Event::End(TagEnd::CodeBlock) => {
171 if let Some(mut b) = open.take() {
172 b.lines.extend(line.take());
174 out.push(b);
175 }
176 }
177 _ => {}
178 }
179 }
180 out
181}
182
183pub fn hide_lines(markdown: &str) -> String {
186 if !markdown.contains('#') {
187 return markdown.to_string();
188 }
189 let mut out = String::with_capacity(markdown.len());
190 let mut from = 0;
191 for b in code_blocks(markdown).iter().filter(|b| b.block.is_nova()) {
192 for l in b.lines.iter().filter(|l| hidden(&l.text).is_some()) {
193 let start = markdown[..l.at].rfind('\n').map_or(0, |i| i + 1);
196 let end = markdown[l.at..]
197 .find('\n')
198 .map_or(markdown.len(), |i| l.at + i + 1);
199 out.push_str(&markdown[from..start]);
200 from = end;
201 }
202 }
203 out.push_str(&markdown[from..]);
204 out
205}
206
207fn shown(code: &str) -> String {
210 code.split_inclusive('\n')
211 .filter(|l| hidden(l.strip_suffix('\n').unwrap_or(l)).is_none())
212 .collect()
213}
214
215#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub(crate) struct Docs {
219 pub html: String,
220 pub summary: String,
221 pub source: DocText,
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
229pub(crate) struct DocText {
230 pub text: String,
231 pub file: Option<FileId>,
234 pub lines: Vec<(usize, u32)>,
237}
238
239impl DocText {
240 pub(crate) fn push(&mut self, file: FileId, at: u32, body: &str) {
242 if !self.lines.is_empty() {
243 self.text.push('\n');
244 }
245 self.lines.push((self.text.len(), at));
246 self.text.push_str(body);
247 self.file = Some(file);
248 }
249
250 pub(crate) fn source(&self, offset: usize) -> Option<u32> {
253 let i = self
254 .lines
255 .partition_point(|&(start, _)| start <= offset)
256 .checked_sub(1)?;
257 let (start, at) = self.lines[i];
258 Some(at + (offset - start) as u32)
259 }
260}
261
262pub(crate) fn join(lines: &[Spanned<String>]) -> DocText {
267 let mut d = DocText::default();
268 for l in lines {
269 let body = l.value.strip_prefix(' ').unwrap_or(&l.value);
270 let skipped = (l.value.len() - body.len()) as u32;
271 d.push(l.span.file, l.span.start + 3 + skipped, body);
272 }
273 d
274}
275
276pub(crate) fn render_source(source: DocText, level: u8) -> Docs {
279 let mut d = render(&source.text, level);
280 d.source = source;
281 d
282}
283
284pub(crate) fn render(text: &str, level: u8) -> Docs {
289 if text.trim().is_empty() {
290 return Docs::default();
291 }
292 let mut events: Vec<Event> = Vec::new();
293 let mut code: Option<(bool, String)> = None;
295 let mut kept: Vec<bool> = Vec::new();
297 for event in Parser::new_ext(text, options()) {
298 if code.is_some() {
299 match event {
300 Event::Text(t) => {
301 if let Some((_, buf)) = code.as_mut() {
302 buf.push_str(&t);
303 }
304 }
305 Event::End(TagEnd::CodeBlock) => {
306 if let Some((nova, buf)) = code.take() {
307 let body = if nova {
308 highlight(&shown(&buf))
309 } else {
310 escape(&buf)
311 };
312 events.push(Event::Html(
313 format!("<pre class=\"code\"><code>{body}</code></pre>\n").into(),
314 ));
315 }
316 }
317 _ => {}
318 }
319 continue;
320 }
321 match event {
322 Event::Start(Tag::CodeBlock(kind)) => {
323 let block = match &kind {
324 CodeBlockKind::Indented => classify(None),
325 CodeBlockKind::Fenced(info) => classify(Some(info)),
326 };
327 code = Some((block.is_nova(), String::new()));
328 }
329 Event::Html(h) | Event::InlineHtml(h) => events.push(Event::Text(h)),
330 Event::Start(Tag::Link { ref dest_url, .. } | Tag::Image { ref dest_url, .. }) => {
333 let keep = linkable(dest_url);
334 kept.push(keep);
335 if keep {
336 events.push(event);
337 }
338 }
339 Event::End(TagEnd::Link | TagEnd::Image) => {
340 if kept.pop().unwrap_or(true) {
341 events.push(event);
342 }
343 }
344 Event::Start(Tag::Heading {
345 level: l,
346 id,
347 classes,
348 attrs,
349 }) => events.push(Event::Start(Tag::Heading {
350 level: shift(l, level),
351 id,
352 classes,
353 attrs,
354 })),
355 Event::End(TagEnd::Heading(l)) => {
356 events.push(Event::End(TagEnd::Heading(shift(l, level))))
357 }
358 other => events.push(other),
359 }
360 }
361 let summary = summary(&events);
362 let mut html = String::new();
363 html::push_html(&mut html, events.into_iter());
364 Docs {
365 html,
366 summary,
367 source: DocText::default(),
368 }
369}
370
371fn linkable(dest: &str) -> bool {
376 let dest = dest.trim_start_matches(|c: char| c.is_whitespace() || c.is_ascii_control());
377 let Some(end) = dest.find([':', '/', '?', '#']) else {
378 return true;
379 };
380 let scheme = &dest[..end];
381 let mut chars = scheme.chars();
382 let is_scheme = dest[end..].starts_with(':')
383 && chars.next().is_some_and(|c| c.is_ascii_alphabetic())
384 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
385 !is_scheme
386 || ["http", "https", "mailto"]
387 .iter()
388 .any(|s| scheme.eq_ignore_ascii_case(s))
389}
390
391fn shift(level: HeadingLevel, by: u8) -> HeadingLevel {
393 let n = by
394 + match level {
395 HeadingLevel::H1 => 1,
396 HeadingLevel::H2 => 2,
397 HeadingLevel::H3 => 3,
398 HeadingLevel::H4 => 4,
399 HeadingLevel::H5 => 5,
400 HeadingLevel::H6 => 6,
401 };
402 match n {
403 1 => HeadingLevel::H1,
404 2 => HeadingLevel::H2,
405 3 => HeadingLevel::H3,
406 4 => HeadingLevel::H4,
407 5 => HeadingLevel::H5,
408 _ => HeadingLevel::H6,
409 }
410}
411
412fn summary(events: &[Event]) -> String {
414 let mut out = String::new();
415 let mut inside = false;
416 for e in events {
417 match e {
418 Event::Start(Tag::Paragraph) => inside = true,
419 Event::End(TagEnd::Paragraph) if inside => break,
420 Event::Text(t) | Event::Code(t) if inside => out.push_str(t),
421 Event::SoftBreak | Event::HardBreak if inside => out.push(' '),
422 _ => {}
423 }
424 }
425 out
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use nova_diagnostics::{FileId, Span};
432
433 #[test]
434 fn a_paragraph_emphasis_a_list_and_a_table() {
435 let d = render(
436 "Some *text*.\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |",
437 3,
438 );
439 assert_eq!(
440 d.html,
441 "<p>Some <em>text</em>.</p>\n<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n\
442 <table><thead><tr><th>a</th><th>b</th></tr></thead><tbody>\n\
443 <tr><td>1</td><td>2</td></tr>\n</tbody></table>\n"
444 );
445 assert_eq!(d.summary, "Some text.");
446 }
447
448 #[test]
449 fn raw_html_shows_as_text() {
450 let d = render("Click <b>here</b>.\n\n<script>alert(1)</script>\n", 3);
452 assert!(!d.html.contains("<script>"), "{}", d.html);
453 assert!(!d.html.contains("<b>"), "{}", d.html);
454 assert!(
455 d.html.contains("<script>alert(1)</script>"),
456 "{}",
457 d.html
458 );
459 assert!(
460 d.html.contains("Click <b>here</b>."),
461 "{}",
462 d.html
463 );
464 }
465
466 #[test]
467 fn a_link_to_a_script_is_its_text_alone() {
468 for (doc, text) in [
470 ("[one](javascript:alert(1))", "one"),
471 ("[two](JavaScript:alert(2))", "two"),
472 ("<javascript:alert(3)>", "javascript:alert(3)"),
473 ("[four](< \tjavascript:alert(4)>)", "four"),
474 ("[five](vbscript:msgbox(5))", "five"),
475 ("[six](data:text/html,six)", "six"),
476 ("[seven][s]\n\n[s]: javascript:alert(7)", "seven"),
477 ] {
478 let d = render(doc, 3);
479 assert!(!d.html.contains("<a"), "{doc}: {}", d.html);
480 assert!(d.html.contains(text), "{doc}: {}", d.html);
481 }
482 let d = render(")", 3);
483 assert!(!d.html.contains("<img"), "{}", d.html);
484 assert_eq!(d.html, "<p>picture</p>\n");
485 let d = render("[)](ok.html)", 3);
486 assert_eq!(d.html, "<p><a href=\"ok.html\">badge</a></p>\n");
487 }
488
489 #[test]
490 fn web_mail_relative_and_fragment_links_stay_links() {
491 let d = render(
492 "[a](https://example.com/) [b](HTTP://example.com/) \
493 [c](mailto:me@example.com) [d](../geom/index.html#fn.area) \
494 [e](#fn.main) [f](dir/a:b.html) ",
495 3,
496 );
497 for href in [
498 "<a href=\"https://example.com/\">a</a>",
499 "<a href=\"HTTP://example.com/\">b</a>",
500 "<a href=\"mailto:me@example.com\">c</a>",
501 "<a href=\"../geom/index.html#fn.area\">d</a>",
502 "<a href=\"#fn.main\">e</a>",
503 "<a href=\"dir/a:b.html\">f</a>",
504 "<img src=\"pic.png\" alt=\"g\" />",
505 ] {
506 assert!(d.html.contains(href), "{href}: {}", d.html);
507 }
508 }
509
510 #[test]
511 fn headings_move_down_below_their_items() {
512 let d = render("# Errors\n\n###### Deep", 3);
513 assert_eq!(d.html, "<h4>Errors</h4>\n<h6>Deep</h6>\n");
514 }
515
516 #[test]
517 fn nova_code_is_highlighted_and_other_code_is_not() {
518 let d = render(
519 "```\nfn f() {}\n```\n\n```text\nfn f() {}\n```\n\n let x = 1\n",
520 3,
521 );
522 assert_eq!(
523 d.html,
524 "<pre class=\"code\"><code><span class=\"kw\">fn</span> f() {}\n</code></pre>\n\
525 <pre class=\"code\"><code>fn f() {}\n</code></pre>\n\
526 <pre class=\"code\"><code><span class=\"kw\">let</span> x = \
527 <span class=\"lit\">1</span>\n</code></pre>\n"
528 );
529 }
530
531 #[test]
532 fn the_summary_is_the_first_paragraph_as_plain_text() {
533 let d = render("Reads `n` bytes,\nor fewer.\n\nThen more.", 3);
534 assert_eq!(d.summary, "Reads n bytes, or fewer.");
535 }
536
537 #[test]
538 fn empty_docs_render_nothing() {
539 assert_eq!(render("", 3), Docs::default());
540 assert_eq!(render(" \n", 3), Docs::default());
541 }
542
543 #[test]
544 fn blocks_are_classified_by_their_first_word() {
545 let test = |unknown: &[&str]| Block::Test {
547 unknown: unknown.iter().map(|w| w.to_string()).collect(),
548 };
549 for (info, block) in [
550 (None, test(&[])),
551 (Some(""), test(&[])),
552 (Some("nova"), test(&[])),
553 (Some("nova,ignore"), Block::Ignored),
554 (Some("nova ignore"), Block::Ignored),
555 (Some("nova, ignore"), Block::Ignored),
556 (Some("ignore"), Block::Ignored),
557 (Some("text"), Block::Other),
558 (Some("sh"), Block::Other),
559 (Some(",text"), Block::Other),
560 (Some("nova,should_panic"), test(&["should_panic"])),
561 (
562 Some("nova,,no_run compile_fail"),
563 test(&["no_run", "compile_fail"]),
564 ),
565 ] {
566 assert_eq!(classify(info), block, "{info:?}");
567 }
568 assert!(test(&[]).is_nova());
569 assert!(Block::Ignored.is_nova());
570 assert!(!Block::Other.is_nova());
571 }
572
573 #[test]
574 fn a_hidden_line_starts_with_a_hash_and_a_space_or_is_a_lone_hash() {
575 assert_eq!(hidden("# let a = 1"), Some((0, 2)));
576 assert_eq!(hidden(" # x"), Some((4, 2)));
577 assert_eq!(hidden("\t# x"), Some((1, 2)));
578 assert_eq!(hidden("#"), Some((0, 1)));
579 assert_eq!(hidden(" #"), Some((2, 1)));
580 assert_eq!(hidden("#x"), None);
581 assert_eq!(hidden("// # x"), None);
582 assert_eq!(hidden("let a = 1 # x"), None);
583 assert_eq!(hidden(""), None);
584 }
585
586 #[test]
587 fn hidden_lines_are_left_out_of_nova_code_on_the_page() {
588 let d = render(
591 "```\n# let a = 1\n#\nlet b = a\n```\n\n```text\n# shown\n```\n\n \
592 # let c = 2\n let d = c\n",
593 3,
594 );
595 assert_eq!(
596 d.html,
597 "<pre class=\"code\"><code><span class=\"kw\">let</span> b = a\n</code></pre>\n\
598 <pre class=\"code\"><code># shown\n</code></pre>\n\
599 <pre class=\"code\"><code><span class=\"kw\">let</span> d = c\n</code></pre>\n"
600 );
601 }
602
603 #[test]
604 fn an_ignored_block_is_highlighted_as_nova() {
605 let d = render("```ignore\n# let a = 1\nfn f() {}\n```", 3);
606 assert_eq!(
607 d.html,
608 "<pre class=\"code\"><code><span class=\"kw\">fn</span> f() {}\n</code></pre>\n"
609 );
610 }
611
612 #[test]
613 fn code_blocks_give_each_line_and_where_it_starts() {
614 let text = "Use:\n\n```nova,ignore\nlet a = 1\n# let b = a\n```\n\n- In a list:\n\n \
615 let c = 3\n\n> ```\n> let d = 4\n> ```\n\n```text\nunclosed\nlast";
616 let at = |s: &str| text.find(s).unwrap();
617 let line = |s: &str| CodeLine {
618 text: s.to_string(),
619 pad: 0,
620 at: at(s),
621 };
622 let test = Block::Test {
623 unknown: Vec::new(),
624 };
625 assert_eq!(
626 code_blocks(text),
627 [
628 CodeBlock {
629 block: Block::Ignored,
630 start: at("```nova"),
631 lines: vec![line("let a = 1"), line("# let b = a")],
632 },
633 CodeBlock {
634 block: test.clone(),
635 start: at("let c"),
636 lines: vec![line("let c = 3")],
637 },
638 CodeBlock {
639 block: test.clone(),
640 start: at("```\n> let d"),
641 lines: vec![line("let d = 4")],
642 },
643 CodeBlock {
644 block: Block::Other,
645 start: at("```text"),
646 lines: vec![line("unclosed"), line("last")],
647 },
648 ]
649 );
650 assert_eq!(
652 code_blocks("- a\n\n \t\tcode"),
653 [CodeBlock {
654 block: test,
655 start: 8,
656 lines: vec![CodeLine {
657 text: " code".to_string(),
658 pad: 2,
659 at: 8,
660 }],
661 }]
662 );
663 }
664
665 #[test]
666 fn hide_lines_cuts_only_the_hidden_lines_of_nova_blocks() {
667 let md = "Use it:\n\n```\n# let x = 1\nlet y = x\n```\n\n```text\n# shown\n```\n\n\
668 > ```nova\n> # q\n> let z = 1\n> ```\n\n# Heading\n\n # let w = 0\n \
669 let v = w\n\n```ignore\n#\nlet u = 1\n```\n";
670 assert_eq!(
671 hide_lines(md),
672 "Use it:\n\n```\nlet y = x\n```\n\n```text\n# shown\n```\n\n\
673 > ```nova\n> let z = 1\n> ```\n\n# Heading\n\n let v = w\n\n\
674 ```ignore\nlet u = 1\n```\n"
675 );
676 assert_eq!(hide_lines("No code # here."), "No code # here.");
677 assert_eq!(hide_lines("# Title\n\nText."), "# Title\n\nText.");
678 }
679
680 #[test]
681 fn lines_join_without_one_leading_space() {
682 let line = |s: &str| Spanned::new(s.to_string(), Span::point(0, FileId::DUMMY));
683 assert_eq!(
684 join(&[line(" One."), line(""), line(" Two.")]).text,
685 "One.\n\n Two."
686 );
687 }
688
689 fn doc_lines(src: &str) -> Vec<Spanned<String>> {
691 nova_lexer::lex(src, FileId::DUMMY)
692 .0
693 .into_iter()
694 .filter_map(|t| match t.value {
695 nova_lexer::Token::DocComment(s) => Some(Spanned::new(s, t.span)),
696 _ => None,
697 })
698 .collect()
699 }
700
701 #[test]
702 fn joined_lines_map_back_to_their_source() {
703 for src in [
706 "/// One.\n///\n///Two.\n /// three\nfn f() {}\n",
707 "/// One.\r\n///\r\n///Two.\r\n /// three\r\nfn f() {}\r\n",
708 ] {
709 let d = join(&doc_lines(src));
710 assert_eq!(d.text, "One.\n\nTwo.\n three", "{src:?}");
711 assert_eq!(d.file, Some(FileId::DUMMY));
712 assert_eq!(d.lines.len(), 4);
713 for word in ["One.", "Two.", "three", "hree"] {
714 assert_eq!(
715 d.source(d.text.find(word).unwrap()),
716 Some(src.find(word).unwrap() as u32),
717 "{word:?} in {src:?}"
718 );
719 }
720 let bare = src.find("\n///").unwrap() as u32 + 1;
722 assert_eq!(d.source(d.text.find("\n\n").unwrap() + 1), Some(bare + 3));
723 }
724 assert_eq!(join(&[]).source(0), None);
725 assert_eq!(join(&[]).file, None);
726 }
727
728 #[test]
729 fn rendered_docs_keep_their_source() {
730 let d = render_source(join(&doc_lines("/// One.\nfn f() {}\n")), 3);
731 assert_eq!(d.html, "<p>One.</p>\n");
732 assert_eq!(d.source.text, "One.");
733 assert_eq!(d.source.source(0), Some(4));
734 assert_eq!(render("One.", 3).source, DocText::default());
735 }
736}