fix: elaborate and render blockquotes in Verso docstrings (#13670)

This PR adds support for blockquotes to Verso docstrings, which had been
missing before. It also substantially improves the robustness of
Verso->Markdown rendering of docstrings, especially the handling of
blockquote line prefixes.
This commit is contained in:
David Thrane Christiansen 2026-05-07 01:59:11 +02:00 committed by GitHub
parent 36a54dbe9c
commit 5d5642107d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 999 additions and 247 deletions

View file

@ -37,7 +37,9 @@ instance : Repr ElabInline where
instance : Doc.MarkdownInline ElabInline where
-- TODO extensibility
toMarkdown go _i content := content.forM go
toMarkdown go _i content := do
let parts ← content.mapM go
return Doc.joinInlines parts
/--
@ -57,7 +59,9 @@ instance : Repr ElabBlock where
-- TODO extensible toMarkdown
instance : Doc.MarkdownBlock ElabInline ElabBlock where
toMarkdown _goI goB _b content := content.forM goB
toMarkdown _goI goB _b content := do
let parts ← content.mapM goB
return Doc.joinBlocks parts
structure VersoDocString where
text : Array (Doc.Block ElabInline ElabBlock)
@ -176,10 +180,9 @@ def findSimpleDocString? (env : Environment) (declName : Name) (includeBuiltin :
where
toMarkdown : VersoDocString → String
| .mk bs ps => Doc.MarkdownM.run' do
for b in bs do
Doc.ToMarkdown.toMarkdown b
for p in ps do
Doc.ToMarkdown.toMarkdown p
let blockLines ← bs.mapM Doc.ToMarkdown.toMarkdown
let partLines ← ps.mapM Doc.ToMarkdown.toMarkdown
return Doc.joinBlocks (blockLines ++ partLines)
structure ModuleDoc where
@ -273,19 +276,13 @@ def addPart (snippet : Snippet) (level : Nat) (range : DeclarationRange) (part :
end VersoModuleDocs.Snippet
open Lean Doc ToMarkdown MarkdownM in
open Lean Doc ToMarkdown in
instance : ToMarkdown VersoModuleDocs.Snippet where
toMarkdown
| {text, sections, ..} => do
text.forM toMarkdown
endBlock
for (level, _, part) in sections do
push ("".pushn '#' (level + 1))
push " "
for i in part.title do toMarkdown i
endBlock
for b in part.content do toMarkdown b
endBlock
let textBlocks ← text.mapM toMarkdown
let sectionBlocks ← sections.mapM fun (level, _, part) => partMarkdown level part
return joinBlocks (textBlocks ++ sectionBlocks)
structure VersoModuleDocs where
snippets : PersistentArray VersoModuleDocs.Snippet := {}

View file

@ -18,152 +18,207 @@ set_option linter.missingDocs true
namespace Lean.Doc
/--
A monad for accumulating footnotes while rendering Markdown. Footnote labels are paired with their
already-rendered bodies, in order of first reference. `MarkdownM.run'` flushes them at the end of
the document.
-/
public abbrev MarkdownM := StateM (Array (String × String))
namespace MarkdownM
/--
The surrounding context of Markdown that's being generated, in order to prevent nestings that
Markdown doesn't allow.
Tracks which inline delimiters are already open so that nested `*` / `**` / `[…](…)` don't emit
redundant openers and closers.
-/
public structure Context where
/-- The current code is inside emphasis. -/
public structure InlineCtx where
/-- The current code is inside emphasis (`*…*`). -/
inEmph : Bool := false
/-- The current code is inside strong emphasis. -/
/-- The current code is inside strong emphasis (`**…**`). -/
inBold : Bool := false
/-- The current code is inside a link. -/
/-- The current code is inside a link's display text. -/
inLink : Bool := false
/-- The prefix that should be added to each line (typically for indentation). -/
linePrefix : String := ""
/-- The state of a Markdown generation task. -/
public structure State where
/-- The blocks prior to the one being generated. -/
priorBlocks : String := ""
/-- The block being generated. -/
currentBlock : String := ""
/-- Footnotes -/
footnotes : Array (String × String) := #[]
def combineBlocks (prior current : String) :=
if prior.isEmpty then current
else if current.isEmpty then prior
else if prior.endsWith "\n\n" then prior ++ current
else if prior.endsWith "\n" then prior ++ "\n" ++ current
else prior ++ "\n\n" ++ current
def State.endBlock (state : State) : State :=
{ state with
priorBlocks :=
combineBlocks state.priorBlocks state.currentBlock ++
(if state.footnotes.isEmpty then ""
else state.footnotes.foldl (init := "\n\n") fun s (n, txt) => s ++ s!"[^{n}]:{txt}\n\n"),
currentBlock := "",
footnotes := #[]
}
def State.render (state : State) : String :=
state.endBlock.priorBlocks
def State.push (state : State) (txt : String) : State :=
{ state with currentBlock := state.currentBlock ++ txt }
def State.endsWith (state : State) (txt : String) : Bool :=
state.currentBlock.endsWith txt || (state.currentBlock.isEmpty && state.priorBlocks.endsWith txt)
deriving Inhabited
end MarkdownM
open MarkdownM in
/--
The monad for generating Markdown output.
Renders an action that produces an array of lines into a single Markdown string, appending any
accumulated footnotes after the main body.
-/
public abbrev MarkdownM := ReaderT Context (StateM State)
public def MarkdownM.run' (act : MarkdownM (Array String)) : String :=
let (lines, footnotes) := act #[]
let main := "\n".intercalate lines.toList
if footnotes.isEmpty then
main
else
let foots := footnotes.toList.map fun (n, t) => s!"[^{n}]:{t}"
main ++ "\n\n" ++ "\n\n".intercalate foots
/--
Generates Markdown, rendering the result from the final state.
Drops trailing ASCII spaces (`' '`) from a string. Used to compute the "empty line" form of a
per-line prefix (`"> "` becomes `">"`) so that prefixed empty lines don't carry trailing whitespace
into the rendered output.
-/
public def MarkdownM.run (act : MarkdownM α) (context : Context := {}) (state : State := {}) : (α × String) :=
let (val, state) := act context state
(val, state.render)
def trimEndSpaces (s : String) : String :=
s.dropEndWhile ' ' |>.copy
/--
Generates Markdown, rendering the result from the final state, without producing a value.
Applies a uniform prefix to every line. Empty lines receive the trimmed prefix to avoid trailing
whitespace (which denotes a hard line break).
-/
public def MarkdownM.run' (act : MarkdownM Unit) (context : Context := {}) (state : State := {}) : String :=
act.run context state |>.2
public def prefixLines (p : String) (lines : Array String) : Array String :=
let pTrim := trimEndSpaces p
lines.map fun l => if l.isEmpty then pTrim else p ++ l
/--
Adds a string to the current Markdown output.
Applies one prefix to the first line and a different prefix to subsequent lines. Used for list items,
where the first line gets `"* "` / `"1. "` and the continuation lines get `" "` / `" "`.
-/
public def MarkdownM.push (txt : String) : MarkdownM Unit := modify (·.push txt)
public def prefixListLines (head rest : String) (lines : Array String) : Array String :=
let headTrim := trimEndSpaces head
let restTrim := trimEndSpaces rest
lines.mapIdx fun i l =>
let (p, pTrim) := if i = 0 then (head, headTrim) else (rest, restTrim)
if l.isEmpty then pTrim else p ++ l
/--
Checks whether the current output ends with the given string.
Concatenates an array of "block" line arrays into one line array, with a single empty separator line
between adjacent non-empty blocks. Empty input blocks are skipped.
-/
public def MarkdownM.endsWith (txt : String) : MarkdownM Bool := do
return (← get).endsWith txt
public def joinBlocks (blocks : Array (Array String)) : Array String :=
blocks.foldl (init := #[]) fun acc b =>
if b.isEmpty then acc
else if acc.isEmpty then b
else acc.push "" ++ b
/--
Terminates the current block.
Concatenates two strings that are the result of rendering consecutive inliens, inserting a U+200B
zero-width space when both concatenated ends are backticks. Markdown has no syntactic way to place
two code spans consecutively, so the zero-width space disambiguates them without introducing visible
whitespace.
-/
public def MarkdownM.endBlock : MarkdownM Unit := modify (·.endBlock)
def glueInlineBoundary (l r : String) : String :=
if l.endsWith "`" ∧ r.startsWith "`" then l ++ "" ++ r
else l ++ r
/--
Increases the indentation level by one.
Concatenates a array of lines, where each line is an array of rendered inlines. The last line of one
piece is glued onto the first line of the next via `glueInlineBoundary` (so `.text "a"` followed by
`.text "b"` yields one line `"ab"`, but two adjacent `.code` spans get a U+200B between them).
-/
public def MarkdownM.indent: MarkdownM α → MarkdownM α :=
withReader fun st => { st with linePrefix := st.linePrefix ++ " " }
public def joinInlines (parts : Array (Array String)) : Array String :=
parts.foldl (init := #[]) fun acc next =>
if h : next.size = 0 then acc
else if h' : acc.size = 0 then next
else
let lastIdx := acc.size - 1
have : 0 < acc.size := Nat.ne_zero_iff_zero_lt.mp h'
have : 0 < next.size := Nat.ne_zero_iff_zero_lt.mp h
have : lastIdx < acc.size := Nat.sub_one_lt h'
let glued := glueInlineBoundary (acc[lastIdx]) next[0]
acc.set lastIdx glued ++ next.drop 1
/--
A means of transforming values to Markdown representations.
A means of transforming values into Markdown lines.
-/
public class ToMarkdown (α : Type u) where
/--
A function that transforms an `α` into a Markdown representation.
Render an `α` to its Markdown lines. The lines should not contain literal `\n`
characters; line breaks are encoded by the array structure.
-/
toMarkdown : α → MarkdownM Unit
toMarkdown : α → MarkdownM (Array String)
/--
A way to transform inline elements extended with `i` into Markdown.
A way to transform inline elements extended with `i` into Markdown lines.
-/
public class MarkdownInline (i : Type u) where
/--
A function that transforms an `i` and its contents into Markdown, given a way to transform the
contents.
Render the `i` and its contents to Markdown lines, given a recursive renderer
for ordinary inline content.
-/
toMarkdown : (Inline i → MarkdownM Unit) → i → Array (Inline i) → MarkdownM Unit
toMarkdown :
(Inline i → MarkdownM (Array String)) → i → Array (Inline i) →
MarkdownM (Array String)
public instance : MarkdownInline Empty where
toMarkdown := nofun
/--
A way to transform block elements extended with `b` that contain inline elements extended with `i`
into Markdown.
A way to transform block elements extended with `b` (containing inline elements
extended with `i`) into Markdown lines.
-/
public class MarkdownBlock (i : Type u) (b : Type v) where
/--
A function that transforms a `b` and its contents into Markdown, given a way to transform the
contents.
Render the `b` and its contents to Markdown lines, given recursive renderers
for inline and block content.
-/
toMarkdown :
(Inline i → MarkdownM Unit) → (Block i b → MarkdownM Unit) →
b → Array (Block i b) → MarkdownM Unit
(Inline i → MarkdownM (Array String)) → (Block i b → MarkdownM (Array String)) →
b → Array (Block i b) → MarkdownM (Array String)
public instance : MarkdownBlock i Empty where
toMarkdown := nofun
/--
Checks whether `c` needs escaping in a context that definitely cannot start a block. `next?` is the
following character, used for the `![…]` image-syntax check.
-/
def midLineSpecial (c : Char) (next? : Option Char) : Bool :=
match c with
| '!' => next? == some '['
| _ => "*_`<[]{}()#".any c
/--
Checks whether `c` needs escaping in a context that could potentially start a block. In these
contexts, potential list and blockquote markers need escaping.
-/
def markerPrefixSpecial (prev? : Option Char) (c : Char) (next? : Option Char) : Bool :=
let endsMarker := next? == some ' ' || next?.isNone
match c with
| '>' => true
| '-' | '+' => endsMarker
| '.' => prev?.any (·.isDigit) && endsMarker
| _ => midLineSpecial c next?
/--
Backslash-escapes Markdown-significant punctuation in `s` so it renders as literal text. `s` must
be a single line.
-/
def escape (s : String) : String := Id.run do
let mut s' := ""
let mut iter := s.startPos
let mut prev? : Option Char := none
-- First, escape everything that could start a block, until that's definitely impossible.
while h : ¬iter.IsAtEnd do
let c := iter.get h
iter := iter.next h
if isSpecial c then
let nextIter := iter.next h
let next? : Option Char :=
if h' : ¬nextIter.IsAtEnd then some (nextIter.get h') else none
if markerPrefixSpecial prev? c next? then
s' := s'.push '\\'
s' := s'.push c
prev? := some c
iter := nextIter
unless c.isDigit || "> -+. \t".any c do
break
-- Now escape more conservatively. prev? is no longer used.
while h : ¬iter.IsAtEnd do
let c := iter.get h
let nextIter := iter.next h
let next? : Option Char :=
if h' : ¬nextIter.IsAtEnd then some (nextIter.get h') else none
if midLineSpecial c next? then
s' := s'.push '\\'
s' := s'.push c
iter := nextIter
return s'
where
isSpecial c := "*_`-+.!<>[]{}()#".any (· == c)
def quoteCode (str : String) : String := Id.run do
/--
Length of the longest unbroken run of `` ` `` characters in `str`.
-/
def longestBacktickRun (str : String) : Nat := Id.run do
let mut longest := 0
let mut current := 0
let mut iter := str.startPos
@ -175,10 +230,44 @@ def quoteCode (str : String) : String := Id.run do
else
longest := max longest current
current := 0
let backticks := "".pushn '`' (max longest current + 1)
return max longest current
/--
A run of `` ` `` characters one longer than any run inside `str` (with a minimum of `atLeast`),
suitable for use as a code block fence or a delimiter for inline code.
-/
def fenceFor (atLeast : Nat) (str : String) : String :=
"".pushn '`' (max atLeast (longestBacktickRun str) + 1)
def quoteCode (str : String) : String :=
let backticks := fenceFor 0 str
let str := if str.startsWith "`" || str.endsWith "`" then " " ++ str ++ " " else str
backticks ++ str ++ backticks
/--
Splits a string into an array of lines on `'\n'`, retaining empty trailing/leading lines (so
`"a\nb\n"` yields `#["a", "b", ""]`). Used to convert code blocks into the line-array representation
used by the rest of the renderer.
-/
def splitNewlines (str : String) : Array String :=
str.split '\n' |>.map (·.copy) |>.toArray
/--
Produces a fenced code block as a line array (with no surrounding blank lines), with a sufficiently
long fence.
-/
def codeBlockLines (str : String) : Array String := Id.run do
let fence := fenceFor 2 str
let body := splitNewlines str
-- Drop a trailing empty so the closing fence isn't preceded by a blank line.
let body := if body.size > 0 && body.back!.isEmpty then body.pop else body
#[fence] ++ body ++ #[fence]
/--
Splits off the leading run of whitespace from an inline tree, returning the whitespace as a plain
string and the remainder as a new inline. This allows emphasis to be applied to strings that start
or end with whitespace, taking the flanking rules of Markdown into account.
-/
partial def trimLeft (inline : Inline i) : (String × Inline i) := go [inline]
where
go : List (Inline i) → String × Inline i
@ -194,168 +283,151 @@ where
| .concat xs :: more => go (xs.toList ++ more)
| here :: more => ("", here ++ .concat more.toArray)
partial def trimRight (inline : Inline i) : (Inline i × String) := go [inline]
/--
Splits off the trailing run of whitespace from an inline tree, returning the remainder as a new
inline and the whitespace as a plain string. This allows emphasis to be applied to strings that
start or end with whitespace, taking the flanking rules of Markdown into account.
-/
partial def trimRight (inline : Inline i) : (Inline i × String) := go #[inline]
where
go : List (Inline i) → Inline i × String
| [] => (.empty, "")
| .text s :: more =>
if s.all Char.isWhitespace then
let (pre, post) := go more
(pre, post ++ s)
else
let s1 := s.takeEndWhile Char.isWhitespace |>.copy
let s2 := s.dropEnd s1.length |>.copy
(.concat more.toArray.reverse ++ .text s2, s1)
| .concat xs :: more => go (xs.reverse.toList ++ more)
| here :: more => (.concat more.toArray.reverse ++ here, "")
go (xs : Array (Inline i)) : Inline i × String :=
if h : xs.size = 0 then (.empty, "")
else
match xs.back (Nat.ne_zero_iff_zero_lt.mp h) with
| .text s =>
if s.all Char.isWhitespace then
let (pre, post) := go xs.pop
(pre, post ++ s)
else
let pos := s.skipSuffixWhile Char.isWhitespace
let ws := s.sliceFrom pos
let nonWs := s.sliceTo pos
(.concat (xs.pop.push (.text nonWs.copy)), ws.copy)
| .concat ys => go (xs.pop ++ ys)
| _ => (.concat xs, "")
/--
Strips leading and trailing whitespace from `inline`, returning the leading whitespace, the stripped
middle (still an `Inline`), and the trailing whitespace, in order. The caller can then re-emit the
leading and trailing whitespace *outside* of emphasis.
-/
def trim (inline : Inline i) : (String × Inline i × String) :=
let (pre, more) := trimLeft inline
let (mid, post) := trimRight more
(pre, mid, post)
open MarkdownM in
partial def inlineMarkdown [MarkdownInline i] : Inline i → MarkdownM Unit
| .text s =>
push (escape s)
| .linebreak s => do
push <| s.replace "\n" ("\n" ++ (← read).linePrefix )
| .emph xs => do
/--
Renders an `Inline i` to its Markdown lines. The `InlineCtx` argument tracks which inline delimiters
are already open so that nested `*` / `**` / `[…](…)` don't emit redundant openers and closers. The
top level is represented by `{}`.
-/
partial def inlineMarkdown [MarkdownInline i] :
InlineCtx → Inline i → MarkdownM (Array String)
| _, .text s => return #[escape s]
| _, .linebreak _ => return #["", ""]
| ctx, .emph xs => do
let (pre, mid, post) := trim (.concat xs)
push pre
unless (← read).inEmph do
push "*"
withReader (fun ρ => { ρ with inEmph := true }) do
inlineMarkdown mid
unless (← read).inEmph do
push "*"
push post
| .bold xs => do
let inner ← inlineMarkdown { ctx with inEmph := true } mid
let mut pieces : Array (Array String) := #[]
if !pre.isEmpty then pieces := pieces.push #[pre]
if !ctx.inEmph then pieces := pieces.push #["*"]
pieces := pieces.push inner
if !ctx.inEmph then pieces := pieces.push #["*"]
if !post.isEmpty then pieces := pieces.push #[post]
return joinInlines pieces
| ctx, .bold xs => do
let (pre, mid, post) := trim (.concat xs)
push pre
unless (← read).inBold do
push "**"
withReader (fun ρ => { ρ with inEmph := true }) do
inlineMarkdown mid
unless (← read).inBold do
push "**"
push post
| .concat xs =>
for i in xs do inlineMarkdown i
| .link content url => do
if (← read).inLink then
for i in content do inlineMarkdown i
let inner ← inlineMarkdown { ctx with inBold := true } mid
let mut pieces : Array (Array String) := #[]
if !pre.isEmpty then pieces := pieces.push #[pre]
if !ctx.inBold then pieces := pieces.push #["**"]
pieces := pieces.push inner
if !ctx.inBold then pieces := pieces.push #["**"]
if !post.isEmpty then pieces := pieces.push #[post]
return joinInlines pieces
| ctx, .concat xs => do
let parts ← xs.mapM (inlineMarkdown ctx)
return joinInlines parts
| ctx, .link content url => do
if ctx.inLink then
let parts ← content.mapM (inlineMarkdown ctx)
return joinInlines parts
else
push "["
for i in content do inlineMarkdown i
push "]("
push url
push ")"
| .image alt url =>
push s!"![{escape alt}]({url})"
| .footnote name content => do
push s!"[ˆ^{name}]"
let footnoteContent := (content.forM inlineMarkdown) {} {} |>.2.render
modify fun st => { st with footnotes := st.footnotes.push (name, footnoteContent) }
| .code str => do
if (← endsWith "`") then
-- Markdown has no reasonable way to put one code element after another. This is a zero-width
-- space to work around this syntactic limitation:
push ""
push (quoteCode str)
| .math .display m => push s!"$${m}$$"
| .math .inline m => push s!"${m}$"
| .other container content => do
MarkdownInline.toMarkdown inlineMarkdown container content
let inner ← inlineMarkdown { ctx with inLink := true } (.concat content)
return joinInlines #[#["["], inner, #["](" ++ url ++ ")"]]
| _, .image alt url => return #[s!"![{escape alt}]({url})"]
| ctx, .footnote name content => do
let parts ← content.mapM (inlineMarkdown ctx)
let footnoteContent := "\n".intercalate (joinInlines parts).toList
modify (·.push (name, footnoteContent))
return #[s!"[^{name}]"]
| _, .code str => return #[quoteCode str]
| _, .math .display m => return #[s!"$${m}$$"]
| _, .math .inline m => return #[s!"${m}$"]
| ctx, .other container content =>
MarkdownInline.toMarkdown (inlineMarkdown ctx) container content
public instance [MarkdownInline i] : ToMarkdown (Inline i) where
toMarkdown inline := private inlineMarkdown inline
toMarkdown := private inlineMarkdown {}
def quoteCodeBlock (indent : Nat) (str : String) : String := Id.run do
let mut longest := 2
let mut current := 0
let mut iter := str.startPos
let mut out := ""
while h : ¬iter.IsAtEnd do
let c := iter.get h
iter := iter.next h
if c == '`' then
current := current + 1
else
longest := max longest current
current := 0
out := out.push c
if c == '\n' then
out := out.pushn ' ' indent
out := if out.endsWith "\n" then out else out.push '\n'
let backticks := "" |>.pushn ' ' indent |>.pushn '`' (max longest current + 1)
backticks ++ "\n" ++ out ++ backticks ++ "\n"
open MarkdownM in
partial def blockMarkdown [MarkdownInline i] [MarkdownBlock i b] : Block i b → MarkdownM Unit
| .para xs => do
for i in xs do
ToMarkdown.toMarkdown i
endBlock
| .concat bs =>
for b in bs do
blockMarkdown b
partial def blockMarkdown [MarkdownInline i] [MarkdownBlock i b] :
Block i b → MarkdownM (Array String)
| .para xs => inlineMarkdown {} (.concat xs)
| .concat bs => do
let parts ← bs.mapM blockMarkdown
return joinBlocks parts
| .blockquote bs => do
withReader (fun ρ => { ρ with linePrefix := ρ.linePrefix ++ "> " })
for b in bs do
blockMarkdown b
endBlock
let parts ← bs.mapM blockMarkdown
return prefixLines "> " (joinBlocks parts)
| .ul items => do
for item in items do
push <| (← read).linePrefix ++ "* "
withReader (fun ρ => { ρ with linePrefix := ρ.linePrefix ++ " " }) do
for b in item.contents do
blockMarkdown b
endBlock
let rendered ← items.mapM fun item => do
let inner ← item.contents.mapM blockMarkdown
return prefixListLines "* " " " (joinBlocks inner)
return joinBlocks rendered
| .ol start items => do
let mut out : Array (Array String) := #[]
let mut n := max 1 start.toNat
for item in items do
push <| (← read).linePrefix ++ s!"{n}. "
withReader (fun ρ => { ρ with linePrefix := ρ.linePrefix ++ " " }) do
for b in item.contents do
blockMarkdown b
let head := s!"{n}. "
let cont := "".pushn ' ' head.length
let inner ← item.contents.mapM blockMarkdown
out := out.push (prefixListLines head cont (joinBlocks inner))
n := n + 1
endBlock
return joinBlocks out
| .dl items => do
for item in items do
push <| (← read).linePrefix ++ "* "
withReader (fun ρ => { ρ with linePrefix := ρ.linePrefix ++ " " }) do
inlineMarkdown (.bold item.term)
inlineMarkdown (.text ": " : Inline i)
push "\n"
push (← read).linePrefix
blockMarkdown (.concat item.desc)
endBlock
| .code str => do
unless (← get).currentBlock.isEmpty || (← get).currentBlock.endsWith "\n" do
push "\n"
push <| quoteCodeBlock (← read).linePrefix.length str
endBlock
let rendered ← items.mapM fun item => do
let term ← inlineMarkdown {} (.bold item.term)
let desc ← item.desc.mapM blockMarkdown
let termWithColon := joinInlines #[term, #[":"]]
-- Single-block descriptions are attached directly to the term. Multi-block descriptions are
-- separated from the term with a blank line so the term doesn't fuse into the
-- first description paragraph.
let body :=
if item.desc.size ≤ 1 then termWithColon ++ joinBlocks desc
else joinBlocks (#[termWithColon] ++ desc)
return prefixListLines "* " " " body
return joinBlocks rendered
| .code str => return codeBlockLines str
| .other container content =>
MarkdownBlock.toMarkdown (i := i) (b := b) inlineMarkdown blockMarkdown container content
MarkdownBlock.toMarkdown (i := i) (b := b) (inlineMarkdown {}) blockMarkdown container content
public instance [MarkdownInline i] [MarkdownBlock i b] : ToMarkdown (Block i b) where
toMarkdown block := private blockMarkdown block
toMarkdown := private blockMarkdown
open MarkdownM in
open ToMarkdown in
partial def partMarkdown [MarkdownInline i] [MarkdownBlock i b] (level : Nat) (part : Part i b p) : MarkdownM Unit := do
push ("".pushn '#' (level + 1))
push " "
for i in part.title do
toMarkdown i
endBlock
for b in part.content do
toMarkdown b
endBlock
for p in part.subParts do
partMarkdown (level + 1) p
/--
Renders a `Part` (a logical document section) at the given heading level. Headings use ATX-style
(`#`, `##`, …) with level `0` rendering as `#`.
-/
public partial def partMarkdown [MarkdownInline i] [MarkdownBlock i b] (level : Nat)
(part : Part i b p) : MarkdownM (Array String) := do
let titleParts ← part.title.mapM ToMarkdown.toMarkdown
let header := "".pushn '#' (level + 1) ++ " "
let titleLine := joinInlines (#[#[header]] ++ titleParts)
let contentBlocks ← part.content.mapM ToMarkdown.toMarkdown
let subPartBlocks ← part.subParts.mapM (partMarkdown (level + 1))
return joinBlocks (#[titleLine] ++ contentBlocks ++ subPartBlocks)
public instance [MarkdownInline i] [MarkdownBlock i b] : ToMarkdown (Part i b p) where
toMarkdown part := private partMarkdown 0 part
toMarkdown part := partMarkdown 0 part

View file

@ -1448,6 +1448,8 @@ public partial def elabBlock (stx : TSyntax `block) : DocM (Block ElabInline Ela
match stx with
| `(block|para[$inls*]) =>
.para <$> inls.mapM elabInline
| `(block| > $blocks*) =>
.blockquote <$> blocks.mapM elabBlock
| `(block|ul{$[* $itemss*]*}) =>
.ul <$> itemss.mapM fun items =>
.mk <$> items.mapM elabBlock

View file

@ -162,7 +162,7 @@ def topLevel := 1
/--
info: #[]
#[{ title := #[Lean.Doc.Inline.text "Top-level header"],
titleString := "Top\\-level header",
titleString := "Top-level header",
metadata := none,
content := #[Lean.Doc.Block.para #[Lean.Doc.Inline.text "Content.", Lean.Doc.Inline.linebreak "\n"]],
subParts := #[] }]

View file

@ -18,10 +18,9 @@ open Lean Elab Command Term
private def toMarkdown : VersoDocString → String
| .mk bs ps => Doc.MarkdownM.run' do
for b in bs do
Doc.ToMarkdown.toMarkdown b
for p in ps do
Doc.ToMarkdown.toMarkdown p
let blockLines ← bs.mapM Doc.ToMarkdown.toMarkdown
let partLines ← ps.mapM Doc.ToMarkdown.toMarkdown
return Doc.joinBlocks (blockLines ++ partLines)
private def manualRw (md : String) : String := md.replace manualRoot "$MANUAL_ROOT/"
@ -158,7 +157,7 @@ Because {assert}`assertTests 2 4 = 6`, it must certainly be addition.
-/
def assertTests (x y : Nat) := x + y
/-- info: Because `assertTests 2 4 = 6`, it must certainly be addition\. -/
/-- info: Because `assertTests 2 4 = 6`, it must certainly be addition. -/
#guard_msgs in
#verso_to_markdown assertTests
@ -169,7 +168,7 @@ The attribute {attr}`simp` registers a simp lemma. Use {attr}`@[simp]`.
-/
def attrTests (x y : Nat) := x + y
/-- info: The attribute `simp` registers a simp lemma\. Use `@[simp]`\. -/
/-- info: The attribute `simp` registers a simp lemma. Use `@[simp]`. -/
#guard_msgs in
#verso_to_markdown attrTests
@ -208,7 +207,7 @@ def givenInstanceTests (x y : Nat) : Nat := x - y
info: ⏎
Invisible: ⏎
There is an `addInst : Add β` and an `inferInstance : Add α`, and `x + y + z`\.
There is an `addInst : Add β` and an `inferInstance : Add α`, and `x + y + z`.
Visible: `Add γ`&`addInst : OfNat γ 5`
@ -222,13 +221,13 @@ info: Invisible: ⏎
Visible:
* For `n`, `givenTests n n = n - n`\.
* For `n`, `givenTests n n = n - n`.
* For `n`, `k : Nat`, `givenTests n k = n - k`\.
* For `n`, `k : Nat`, `givenTests n k = n - k`.
* For `n`, `k`, `givenTests n k = n - k`\.
* For `n`, `k`, `givenTests n k = n - k`.
* For `k`, `givenTests m k = m - k`\.
* For `k`, `givenTests m k = m - k`.
-/
#guard_msgs in
#verso_to_markdown givenTests

View file

@ -0,0 +1,682 @@
import Lean.Elab.Command
import Lean.DocString.Markdown
/-!
This test ensures that the rendering of all Verso docstrings to Markdown is sensible.
This rendering is used when showing Verso docstrings over LSP.
-/
open Lean Elab Command Term Doc
private def render (b : Block Empty Empty) : String :=
MarkdownM.run' (ToMarkdown.toMarkdown b)
/--
Renders `b` to Markdown and prints it preceded by a blank line, so the rendered output reads cleanly
inside `#guard_msgs` expected output. The leading blank line keeps the first rendered line visually
separated from the `info:` marker.
-/
private def showMd (b : Block Empty Empty) : IO Unit := do
IO.println ""
IO.println (render b)
/-! Blockquote with one paragraph -/
/--
info:
> A
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.blockquote #[.para #[.text "A"]])
/-! Blockquote with two paragraphs -/
/--
info:
> A
>
> B
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.blockquote #[.para #[.text "A"], .para #[.text "B"]])
/-! Nested blockquote -/
/--
info:
> > X
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.blockquote #[.blockquote #[.para #[.text "X"]]])
/-! Blockquote with inline linebreak -/
/--
info:
> A
> B
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.blockquote #[.para #[.text "A", .linebreak "\n", .text "B"]])
/-! Blockquote inside an unordered list item -/
/--
info:
* > X
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ul #[⟨#[.blockquote #[.para #[.text "X"]]]⟩])
/--
info:
* > X
> Y
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ul #[⟨#[.blockquote #[.para #[.text "X", .linebreak "\n", .text "Y"]]]⟩])
/--
info:
* > X
> Y
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ul #[⟨#[.blockquote #[.para #[.text "X"]], .blockquote #[.para #[.text "Y"]]]⟩])
/-! Blockquote of two paragraphs inside an unordered list item -/
/--
info:
* > A
>
> B
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ul #[⟨#[.blockquote #[.para #[.text "A"], .para #[.text "B"]]]⟩])
/-! Blockquote of a fenced code block -/
/--
info:
> ```
> x = 1
> ```
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.blockquote #[.code "x = 1"])
/-! Blockquote → ordered list → blockquotes (multi-line; one item has two paragraphs) -/
/--
info:
> 1. > First line of item 1
> >
> > Second paragraph of item 1
>
> 2. > Item 2 line 1
> > Item 2 line 2
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.blockquote #[
.ol 1 #[
⟨#[.blockquote #[
.para #[.text "First line of item 1"],
.para #[.text "Second paragraph of item 1"]]]⟩,
⟨#[.blockquote #[
.para #[.text "Item 2 line 1", .linebreak "\n", .text "Item 2 line 2"]]]⟩
]
]
/-! List with two paragraphs -/
/--
info:
* X
Y
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ul #[⟨#[.para #[.text "X"], .para #[.text "Y"]]⟩])
/-! An ordered list with ≥ 10 items keeps continuation prefix width consistent -/
/--
info:
10. X
Y
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ol 10 #[⟨#[.para #[.text "X"], .para #[.text "Y"]]⟩])
/--
info:
1. X
Y
2. X
Y
3. X
Y
4. X
Y
5. X
Y
6. X
Y
7. X
Y
8. X
Y
9. X
Y
10. X
Y
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.ol 1 <| List.replicate 10 ⟨#[.para #[.text "X"], .para #[.text "Y"]]⟩ |>.toArray)
/-! Nested definition list (a `.dl` inside another `.dl`'s description) -/
/--
info:
* **outer**:
* **inner**:
value
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.dl #[⟨#[.text "outer"], #[
.dl #[⟨#[.text "inner"], #[.para #[.text "value"]]⟩]
]⟩]
/-! Definition list inside a blockquote -/
/--
info:
> * **k**:
> v
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.blockquote #[
.dl #[⟨#[.text "k"], #[.para #[.text "v"]]⟩]
]
/-!
Definition list with a multi-paragraph descripti
The term is separated from the description by a blank line so a CommonMark
parser keeps `**k**:` as its own paragraph instead of fusing it with the first
description paragraph (the latter would produce `<p>**k**: v1</p>`).
-/
/--
info:
* **k**:
v1
v2
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.dl #[⟨#[.text "k"], #[
.para #[.text "v1"],
.para #[.text "v2"]
]⟩]
/-! Definition list with a description that mixes a paragraph and a code block -/
/--
info:
* **fn**:
Computes the answer.
```
def f x := x
```
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.dl #[⟨#[.text "fn"], #[
.para #[.text "Computes the answer."],
.code "def f x := x"
]⟩]
/-!
Definition list whose single-block description is itself a code blo
A code fence interrupts a paragraph cleanly in CommonMark, so the renderer
keeps the compact (term-glued) form for single-block descriptions even when
the block isn't a paragraph.
-/
/--
info:
* **fn**:
```
def f x := x
```
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.dl #[⟨#[.text "fn"], #[.code "def f x := x"]⟩]
/-!
## Inline constructor covera
Each `Inline` constructor (`text`, `emph`, `bold`, `code`, `math` (`inline`,
`display`), `linebreak`, `link`, `footnote`, `image`, `concat`, `other`) is
exercised below. The `other` case requires a non-`Empty` extension type and is
left to `versoDocMarkdown.lean`.
-/
/-! `.text`: plain text is rendered verbatim, with Markdown specials escaped -/
/--
info:
hello \(world\)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "hello (world)"])
/-! `.text`: end-of-sentence periods are *not* escaped (only digit-`.` is, to avoid forming an ol marker) -/
/--
info:
A sentence. Another sentence.
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "A sentence. Another sentence."])
/--
info:
Step 1. do the thing.
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "Step 1. do the thing."])
/-! `.text`: `-`/`+` are only escaped at the start of a line followed by a space -/
/--
info:
\- bullet
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "- bullet"])
/--
info:
co-author and 1+1
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "co-author and 1+1"])
/-! `.text`: `!` is only escaped when followed by `[` (to avoid forming image syntax) -/
/--
info:
hello! world \!\[not an image\]
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "hello! world ![not an image]"])
/-! `.emph`: emphasis is wrapped in `*…*` -/
/--
info:
*italic*
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.emph #[.text "italic"]])
/-! `.bold`: strong emphasis is wrapped in `**…**` -/
/--
info:
**bold**
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.text "bold"]])
/-!
`.code`: inline code is wrapped in backticks; adjacent code spans get a U+200B separat
The two adjacent code spans here would otherwise butt against each other; the
zero-width space (U+200B) keeps them syntactically distinct.
-/
/--
info:
`x``y`
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.code "x", .code "y"])
/-! `.math .inline`: inline math is wrapped in `$…$` -/
/--
info:
$x^2$
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.math .inline "x^2"])
/-! `.math .display`: display math is wrapped in `$$…$$` -/
/--
info:
$$x^2$$
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.math .display "x^2"])
/-! `.linebreak`: a hard line break inside a paragraph -/
/--
info:
left
right
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "left", .linebreak "\n", .text "right"])
/-! `.link`: a link rendered as `[text](url)` -/
/--
info:
[click](https://example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.link #[.text "click"] "https://example.com"])
/-! `.footnote`: a `[^name]` reference plus a `[^name]:body` flushed at the end -/
/--
info:
see [^fn1]
[^fn1]:the explanation
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "see ", .footnote "fn1" #[.text "the explanation"]])
/-! `.image`: an inline image rendered as `![alt](url)` -/
/--
info:
![cat](cat.png)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.image "cat" "cat.png"])
/-! `.concat`: an inline `concat` aggregates adjacent inlines on one line -/
/--
info:
ab
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.concat #[.text "a", .text "b"]])
/-!
## Inline nestin
Combinations of `.emph`, `.bold`, `.link`, and `.image`. The renderer must avoid
double-emitting `*` / `**` markers when they're already open, and the `.link`
case must avoid emitting nested `[…](…)` when already inside a link.
-/
/-! Emph inside bold renders as `***…***` -/
/--
info:
***x***
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.emph #[.text "x"]]])
/-! Bold inside emph renders as `***…***` -/
/--
info:
***x***
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.emph #[.bold #[.text "x"]]])
/-! Emph inside emph: the inner `*` markers are suppressed -/
/--
info:
*x*
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.emph #[.emph #[.text "x"]]])
/-! Bold inside bold: the inner `**` markers are suppressed -/
/--
info:
**x**
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.bold #[.text "x"]]])
/-!
Emph with internal whitespace: leading/trailing spaces are hoisted outside the `*` marke
CommonMark refuses to parse `* x *` as emphasis (the markers can't be padded).
The renderer peels the whitespace out so the emphasized middle has tight markers.
-/
/--
info:
a *x* b
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.text "a", .emph #[.text " x "], .text "b"])
/-! Bold containing inline code: `**…`x`…**` -/
/--
info:
**a `x` b**
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.text "a ", .code "x", .text " b"]])
/-! Emph inside a link: `[*x*](url)` -/
/--
info:
[*x*](https://example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.link #[.emph #[.text "x"]] "https://example.com"])
/-! Bold inside a link: `[**x**](url)` -/
/--
info:
[**x**](https://example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.link #[.bold #[.text "x"]] "https://example.com"])
/-! Inline code inside a link -/
/--
info:
[`x`](https://example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.link #[.code "x"] "https://example.com"])
/-! Link inside emph: `*[x](url)*` -/
/--
info:
*[x](https://example.com)*
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.emph #[.link #[.text "x"] "https://example.com"]])
/-! Link inside bold: `**[x](url)**` -/
/--
info:
**[x](https://example.com)**
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.link #[.text "x"] "https://example.com"]])
/-!
Nested links: the inner link's URL is dropped and its text rendered inli
CommonMark does not allow nested `[…](…)`, so the renderer flattens the inner
link to its display text only.
-/
/--
info:
[outer x inner](https://outer.example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd <|
.para #[.link #[
.text "outer ",
.link #[.text "x"] "https://inner.example.com",
.text " inner"
] "https://outer.example.com"]
/-! Linked image: `[![alt](img)](url)` -/
/--
info:
[![cat](cat.png)](https://example.com)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.link #[.image "cat" "cat.png"] "https://example.com"])
/-! Image alt text is escaped: Markdown specials in `alt` get backslash-escaped -/
/--
info:
![a \(b\) \*c\*](cat.png)
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.image "a (b) *c*" "cat.png"])
/-! Image inside bold: `**![alt](img)**` -/
/--
info:
**![cat](cat.png)**
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.bold #[.image "cat" "cat.png"]])
/-! Image inside emph: `*![alt](img)*` -/
/--
info:
*![cat](cat.png)*
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.para #[.emph #[.image "cat" "cat.png"]])
/-!
## Block constructor covera
`Block` constructors covered above: `para`, `code`, `ul`, `ol`, `dl`, `blockquote`.
The `concat` case is exercised below. `other` requires a non-`Empty` extension and
is left to `versoDocMarkdown.lean`.
-/
/-! `.concat`: a block `concat` joins adjacent blocks with a blank line between -/
/--
info:
A
B
-/
#guard_msgs in
#eval show CommandElabM Unit from do
showMd (.concat #[.para #[.text "A"], .para #[.text "B"]])