Skip to main content

nova_doc/
markdown.rs

1//! Docs as Markdown (spec §9.1, §9.3; plan decision 12), and the one rule
2//! for code blocks and hidden lines that the site, doc tests and hover
3//! share (3.5b spec §4.2, §4.3).
4
5use nova_diagnostics::{FileId, Spanned};
6use pulldown_cmark::{html, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::escape;
9use crate::highlight::highlight;
10
11/// The options the site renders with, and doc tests and hover read with,
12/// so all three find the same code blocks: a table can end a block.
13fn 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/// What a code block is (3.5b spec §4.2).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Block {
23    /// Nova, highlighted and run as a doc test. `unknown` holds the words
24    /// after `nova` that mean nothing, which `nova test` warns about.
25    Test {
26        /// The unknown words, in order.
27        unknown: Vec<String>,
28    },
29    /// Nova, highlighted, not run: its first word is `ignore`, or a word
30    /// after `nova` is.
31    Ignored,
32    /// Another language, shown as written.
33    Other,
34}
35
36impl Block {
37    /// Whether it is Nova: highlighted, its hidden lines left out.
38    pub fn is_nova(&self) -> bool {
39        !matches!(self, Block::Other)
40    }
41}
42
43/// The block whose info string is `info`, or an indented block for `None`
44/// (3.5b spec §4.2). The info string is split at commas and whitespace
45/// into words, and empty words are dropped.
46pub 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
74/// For a hidden line of a Nova block (3.5b spec §4.3), where its `# `, or
75/// its lone `#`, starts and how many bytes it is; `None` for a line that
76/// shows. It runs less that mark.
77pub 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/// A line of a code block.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CodeLine {
92    /// The line as the block holds it, without its line break.
93    pub text: String,
94    /// How many bytes at its start the parser made up: the rest of a tab
95    /// that a container or the block's indentation took part of.
96    pub pad: usize,
97    /// Where `text[pad..]` starts in the Markdown.
98    pub at: usize,
99}
100
101/// A code block, and where its lines are in the Markdown.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CodeBlock {
104    /// What it is.
105    pub block: Block,
106    /// Where it starts in the Markdown: its opening fence, or an indented
107    /// block's first line.
108    pub start: usize,
109    /// Its lines, in order.
110    pub lines: Vec<CodeLine>,
111}
112
113/// Every code block of the Markdown `text`, as the site finds them.
114pub 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                    // Spaces the parser made up for a tab's remainder.
137                    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                // `t` is `text[range]`: perhaps several lines, perhaps
148                // starting with the line break of a CRLF line.
149                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                    // An unclosed fence's last line has no line break.
173                    b.lines.extend(line.take());
174                    out.push(b);
175                }
176            }
177            _ => {}
178        }
179    }
180    out
181}
182
183/// `markdown` with the hidden lines of its Nova blocks left out (3.5b spec
184/// §8.2), every other byte as it was. Hover shows docs through it.
185pub 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            // The whole Markdown line, with its `> ` or list indentation
194            // and its line break.
195            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
207/// A Nova block's code as the page shows it, its hidden lines left out
208/// (3.5b spec §8.1).
209fn 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/// Docs, rendered: their HTML, and their first paragraph as plain text,
216/// which lists and search results show (spec §9.1).
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub(crate) struct Docs {
219    pub html: String,
220    pub summary: String,
221    /// The Markdown they were rendered from, and where its lines are in
222    /// their file; empty for docs no file holds (3.5b spec §11.1).
223    pub source: DocText,
224}
225
226/// Docs as one Markdown text, and where each of its lines starts in its
227/// file (3.5b spec §7).
228#[derive(Debug, Clone, Default, PartialEq, Eq)]
229pub(crate) struct DocText {
230    pub text: String,
231    /// The file its lines are in; `None` for docs no file holds, as a
232    /// primitive's or a builtin's.
233    pub file: Option<FileId>,
234    /// For each line of `text`: where it starts in `text`, and where that
235    /// byte is in `file`.
236    pub lines: Vec<(usize, u32)>,
237}
238
239impl DocText {
240    /// Adds a line, `body`, whose first byte is byte `at` of `file`.
241    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    /// Where byte `offset` of `text` is in `file`: each line is a run of
251    /// its file's bytes.
252    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
262/// Doc lines as the AST keeps them, each the text after its `///`, as one
263/// Markdown text: each line loses one leading space. A line's text starts
264/// three bytes into its span, after `///`; the lexer drops only trailing
265/// whitespace, a `\r` included (3.5b spec §7).
266pub(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
276/// Docs rendered as [`render`] renders them, keeping their Markdown and
277/// its place in the source, which doc tests read (3.5b spec §11.1).
278pub(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
284/// `text` rendered (spec §9.1): raw HTML as text, a link or an image to
285/// a script as its text alone, headings moved down `level` levels, and
286/// Nova code blocks coloured (§9.3), `ignore` blocks among them, without
287/// their hidden lines (3.5b spec §8.1).
288pub(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    // A code block being read: whether it is Nova, and its text so far.
294    let mut code: Option<(bool, String)> = None;
295    // For each link or image open, whether it is kept (decision 10).
296    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            // A link or an image to a script is dropped, and its text
331            // stays as plain text.
332            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
371/// Whether `dest` may be a link's or an image's URL: a relative URL, a
372/// `#fragment`, or one whose scheme is `http`, `https` or `mailto`. Any
373/// other scheme, as `javascript:`, could put a script into the site
374/// (spec §9.1, decision 10).
375fn 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
391/// `level` moved down `by` levels, stopping at `<h6>` (plan decision 12).
392fn 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
412/// The first paragraph's text, without its markup.
413fn 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        // Review Focus 2.
451        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("&lt;script&gt;alert(1)&lt;/script&gt;"),
456            "{}",
457            d.html
458        );
459        assert!(
460            d.html.contains("Click &lt;b&gt;here&lt;/b&gt;."),
461            "{}",
462            d.html
463        );
464    }
465
466    #[test]
467    fn a_link_to_a_script_is_its_text_alone() {
468        // Decision 10: a dependency's docs cannot put a script into the site.
469        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("![picture](javascript:alert(8))", 3);
483        assert!(!d.html.contains("<img"), "{}", d.html);
484        assert_eq!(d.html, "<p>picture</p>\n");
485        let d = render("[![badge](javascript:alert(9))](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) ![g](pic.png)",
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        // 3.5b spec §4.2's table, and an unknown word.
546        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        // Mutant 1: a `# ` line of a Nova block is not shown; one of a
589        // `text` block is.
590        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        // A tab a list item took part of: two spaces made up before `code`.
651        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    /// The `///` lines of `src`, as the parser keeps them.
690    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        // 3.5b spec §7: a line with and without its leading space, a bare
704        // `///`, an indented one, and the same with CRLF.
705        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            // A bare `///` is the end of its `///`.
721            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}