From 750b7624e031dbec4a890683a5154e96af3d6b95 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Sat, 9 May 2020 19:25:47 +0900 Subject: [PATCH] refactor readers.jl: - avoid unnecessary overloading on `parse_doc` - split readers.jl into multiple scripts --- src/Weave.jl | 2 +- src/chunks.jl | 14 +- src/reader/markdown.jl | 91 ++++++++++ src/reader/notebook.jl | 29 +++ src/reader/reader.jl | 115 ++++++++++++ src/reader/script.jl | 102 +++++++++++ src/readers.jl | 318 --------------------------------- test/errors_test.jl | 8 +- test/formatter_test.jl | 6 +- test/inline_test.jl | 8 +- test/test_module_evaluation.jl | 2 +- 11 files changed, 347 insertions(+), 348 deletions(-) create mode 100644 src/reader/markdown.jl create mode 100644 src/reader/notebook.jl create mode 100644 src/reader/reader.jl create mode 100644 src/reader/script.jl delete mode 100644 src/readers.jl diff --git a/src/Weave.jl b/src/Weave.jl index 0ed4b51..a7b2dc4 100644 --- a/src/Weave.jl +++ b/src/Weave.jl @@ -300,7 +300,7 @@ include("chunks.jl") include("config.jl") include("WeaveMarkdown/markdown.jl") include("display_methods.jl") -include("readers.jl") +include("reader/reader.jl") include("run.jl") include("cache.jl") include("formatters.jl") diff --git a/src/chunks.jl b/src/chunks.jl index f7990be..20e0b03 100644 --- a/src/chunks.jl +++ b/src/chunks.jl @@ -1,7 +1,6 @@ import Mustache abstract type WeaveChunk end -abstract type Inline end mutable struct WeaveDoc source::AbstractString @@ -75,19 +74,12 @@ mutable struct CodeChunk <: WeaveChunk end end +abstract type Inline end + mutable struct DocChunk <: WeaveChunk - content::Array{Inline} + content::Vector{Inline} number::Int start_line::Int - function DocChunk( - text::AbstractString, - number::Int, - start_line::Int, - inline_regex = nothing, - ) - chunks = parse_inline(text, inline_regex) - new(chunks, number, start_line) - end end mutable struct InlineText <: Inline diff --git a/src/reader/markdown.jl b/src/reader/markdown.jl new file mode 100644 index 0000000..231cd0e --- /dev/null +++ b/src/reader/markdown.jl @@ -0,0 +1,91 @@ +""" + parse_markdown(document_body, is_pandoc = false)::Vector{WeaveChunk} + parse_markdown(document_body, code_start, code_end)::Vector{WeaveChunk} + +Parses Weave markdown and returns [`WeaveChunk`](@ref)s. +""" +function parse_markdown end + +function parse_markdown(document_body, is_pandoc = false)::Vector{WeaveChunk} + code_start, code_end = if is_pandoc + r"^<<(?.*?)>>=\s*$", + r"^@\s*$" + else + r"^[`~]{3}(?:\{?)julia(?:;?)\s*(?.*?)(\}|\s*)$", + r"^[`~]{3}\s*$" + end + return parse_markdown(document_body, code_start, code_end) +end + +function parse_markdown(document_body, code_start, code_end)::Vector{WeaveChunk} + lines = split(document_body, '\n') + + state = "doc" + + docno = 1 + codeno = 1 + content = "" + start_line = 0 + + options = Dict() + optionString = "" + chunks = WeaveChunk[] + for (lineno, line) in enumerate(lines) + m = match(code_start, line) + if !isnothing(m) && state == "doc" + state = "code" + if m.captures[1] == nothing + optionString = "" + else + optionString = strip(m.captures[1]) + end + + options = Dict{Symbol,Any}() + if length(optionString) > 0 + expr = Meta.parse(optionString) + Base.Meta.isexpr(expr, :(=)) && (options[expr.args[1]] = expr.args[2]) + Base.Meta.isexpr(expr, :toplevel) && + map(pushopt, fill(options, length(expr.args)), expr.args) + end + haskey(options, :label) && (options[:name] = options[:label]) + haskey(options, :name) || (options[:name] = nothing) + + if !isempty(strip(content)) + chunk = DocChunk(content, docno, start_line) + docno += 1 + push!(chunks, chunk) + end + + content = "" + start_line = lineno + + continue + end + + if occursin(code_end, line) && state == "code" + chunk = CodeChunk(content, codeno, start_line, optionString, options) + + codeno += 1 + start_line = lineno + content = "" + state = "doc" + push!(chunks, chunk) + continue + end + + if lineno == 1 + content *= line + else + content *= "\n" * line + end + end + + # Remember the last chunk + if strip(content) != "" + chunk = DocChunk(content, docno, start_line) + # chunk = Dict{Symbol,Any}(:type => "doc", :content => content, + # :number => docno, :start_line => start_line) + push!(chunks, chunk) + end + return chunks +end diff --git a/src/reader/notebook.jl b/src/reader/notebook.jl new file mode 100644 index 0000000..91bd5bf --- /dev/null +++ b/src/reader/notebook.jl @@ -0,0 +1,29 @@ +""" + parse_notebook(document_body)::Vector{WeaveChunk} + +Parses Jupyter notebook and returns [`WeaveChunk`](@ref)s. +""" +function parse_notebook(document_body)::Vector{WeaveChunk} + nb = JSON.parse(document_body) + chunks = WeaveChunk[] + options = Dict{Symbol,Any}() + opt_string = "" + docno = 1 + codeno = 1 + + for cell in nb["cells"] + srctext = "\n" * join(cell["source"], "") + + if cell["cell_type"] == "code" + chunk = CodeChunk(rstrip(srctext), codeno, 0, opt_string, options) + push!(chunks, chunk) + codeno += 1 + else + chunk = DocChunk(srctext * "\n", docno, 0; notebook = true) + push!(chunks, chunk) + docno += 1 + end + end + + return chunks +end diff --git a/src/reader/reader.jl b/src/reader/reader.jl new file mode 100644 index 0000000..6c1c3e9 --- /dev/null +++ b/src/reader/reader.jl @@ -0,0 +1,115 @@ +using JSON, YAML + + +""" + read_doc(source::AbstractString, format = :auto) -> WeaveDoc + +Read the input document from `source` and parse it into [`WeaveDoc`](@ref). +""" +function read_doc(source::AbstractString, format = :auto) + document = replace(read(source, String), "\r\n" => "\n") # fix line ending + + format === :auto && (format = detect_informat(source)) + chunks = parse_doc(document, format) + header = parse_header(first(chunks)) + doc = WeaveDoc(source, chunks, header) + haskey(header, "options") && header_chunk_defaults!(doc) # TODO: rename `options` => `weave_options` + + return doc +end + +""" + detect_informat(source::AbstractString) + +Detect Weave input format based on file extension of `source`. +""" +function detect_informat(source::AbstractString) + ext = lowercase(last(splitext(source))) + + ext == ".jl" && return "script" + ext == ".jmd" && return "markdown" + ext == ".ipynb" && return "notebook" + return "noweb" +end + +function parse_doc(document, format) + return if format == "markdown" + parse_markdown(document) + elseif format == "noweb" + parse_markdown(document, true) + elseif format == "script" + parse_script(document) + elseif format == "notebook" + parse_notebook(document) + else + error("unsupported format given: $(format)") + end +end + +function pushopt(options::Dict, expr::Expr) + if Base.Meta.isexpr(expr, :(=)) + options[expr.args[1]] = expr.args[2] + end +end + +# inline +# ------ + +function DocChunk(text::AbstractString, number, start_line; notebook = false) + # don't parse inline code in notebook + content = notebook ? parse_inline(text) : parse_inlines(text) + return DocChunk(content, number, start_line) +end + +const INLINE_REGEX = r"`j\s+(.*?)`|^!\s(.*)$"m + +function parse_inlines(text)::Vector{Inline} + occursin(INLINE_REGEX, text) || return parse_inline(text) + + inline_chunks = eachmatch(INLINE_REGEX, text) + s = 1 + e = 1 + res = Inline[] + textno = 1 + codeno = 1 + + for ic in inline_chunks + s = ic.offset + doc = InlineText(text[e:(s-1)], e, s - 1, textno) + textno += 1 + push!(res, doc) + e = s + lastindex(ic.match) + ic.captures[1] !== nothing && (ctype = :inline) + ic.captures[2] !== nothing && (ctype = :line) + cap = filter(x -> x !== nothing, ic.captures)[1] + push!(res, InlineCode(cap, s, e, codeno, ctype)) + codeno += 1 + end + push!(res, InlineText(text[e:end], e, length(text), textno)) + + return res +end + +parse_inline(text) = Inline[InlineText(text, 1, length(text), 1)] + +# headers +# ------- + +parse_header(chunk::CodeChunk) = Dict() + +const HEADER_REGEX = r"^---$(?
((?!---).)+)^---$"ms + +function parse_header(chunk::DocChunk) + m = match(HEADER_REGEX, chunk.content[1].content) + if m !== nothing + header = YAML.load(string(m[:header])) + else + header = Dict() + end + return header +end + + +include("markdown.jl") +include("script.jl") +include("notebook.jl") diff --git a/src/reader/script.jl b/src/reader/script.jl new file mode 100644 index 0000000..a3c2257 --- /dev/null +++ b/src/reader/script.jl @@ -0,0 +1,102 @@ +""" + parse_script(document_body)::Vector{WeaveChunk} + +Parse Julia script and returns [`WeaveChunk`](@ref)s. +""" +function parse_script(document_body)::Vector{WeaveChunk} + lines = split(document_body, "\n") + + doc_line = r"(^#'.*)|(^#%%.*)|(^# %%.*)" + doc_start = r"(^#')|(^#%%)|(^# %%)" + opt_line = r"(^#\+.*$)|(^#%%\+.*$)|(^# %%\+.*$)" + opt_start = r"(^#\+)|(^#%%\+)|(^# %%\+)" + + read = "" + docno = 1 + codeno = 1 + content = "" + start_line = 1 + options = Dict{Symbol,Any}() + optionString = "" + chunks = WeaveChunk[] + state = "code" + lineno = 1 + n_emptylines = 0 + + for lineno = 1:length(lines) + line = lines[lineno] + if (m = match(doc_line, line)) != nothing && (m = match(opt_line, line)) == nothing + line = replace(line, doc_start => "", count = 1) + if startswith(line, " ") + line = replace(line, " " => "", count = 1) + end + if state == "code" && strip(read) != "" + chunk = + CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) + push!(chunks, chunk) + codeno += 1 + read = "" + start_line = lineno + end + state = "doc" + elseif (m = match(opt_line, line)) != nothing + start_line = lineno + if state == "code" && strip(read) != "" + chunk = + CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) + push!(chunks, chunk) + read = "" + codeno += 1 + end + if state == "doc" && strip(read) != "" + (docno > 1) && (read = "\n" * read) # Add whitespace to doc chunk. Needed for markdown output + chunk = DocChunk(read, docno, start_line) + push!(chunks, chunk) + read = "" + docno += 1 + end + + optionString = replace(line, opt_start => "", count = 1) + # Get options + options = Dict{Symbol,Any}() + if length(optionString) > 0 + expr = Meta.parse(optionString) + Base.Meta.isexpr(expr, :(=)) && (options[expr.args[1]] = expr.args[2]) + Base.Meta.isexpr(expr, :toplevel) && + map(pushopt, fill(options, length(expr.args)), expr.args) + end + haskey(options, :label) && (options[:name] = options[:label]) + haskey(options, :name) || (options[:name] = nothing) + + state = "code" + continue + elseif state == "doc" # && strip(line) != "" && strip(read) != "" + state = "code" + (docno > 1) && (read = "\n" * read) # Add whitespace to doc chunk. Needed for markdown output + chunk = DocChunk(read, docno, start_line) + push!(chunks, chunk) + options = Dict{Symbol,Any}() + start_line = lineno + read = "" + docno += 1 + end + read *= line * "\n" + + if strip(line) == "" + n_emptylines += 1 + else + n_emptylines = 0 + end + end + + # Handle the last chunk + if state == "code" + chunk = CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) + push!(chunks, chunk) + else + chunk = DocChunk(read, docno, start_line) + push!(chunks, chunk) + end + + return chunks +end diff --git a/src/readers.jl b/src/readers.jl deleted file mode 100644 index b986fc6..0000000 --- a/src/readers.jl +++ /dev/null @@ -1,318 +0,0 @@ -import JSON, YAML - -pushopt(options::Dict, expr::Expr) = - Base.Meta.isexpr(expr, :(=)) && (options[expr.args[1]] = expr.args[2]) - -mutable struct MarkupInput - codestart::Regex - codeend::Regex - inline::Regex -end - -mutable struct ScriptInput - doc_line::Regex - doc_start::Regex - opt_line::Regex - opt_start::Regex - inline::Regex -end - -mutable struct NotebookInput - inline::Any -end - -const input_formats = Dict{AbstractString,Any}( - "noweb" => MarkupInput(r"^<<(.*?)>>=\s*$", r"^@\s*$", r"`j\s+(.*?)`|^!\s(.*)$"m), - "markdown" => MarkupInput( - r"^[`~]{3,}(?:\{|\{\.|)julia(?:;|)\s*(.*?)(\}|\s*)$", - r"^[`~]{3,}\s*$", - r"`j\s+(.*?)`|^!\s(.*)$"m, - ), - "script" => ScriptInput( - r"(^#'.*)|(^#%%.*)|(^# %%.*)", - r"(^#')|(^#%%)|(^# %%)", - r"(^#\+.*$)|(^#%%\+.*$)|(^# %%\+.*$)", - r"(^#\+)|(^#%%\+)|(^# %%\+)", - r"`j\s+(.*?)`|^!\s(.*)$"m, - ), - "notebook" => NotebookInput(nothing), # Don't parse inline code from notebooks -) - -"""Detect the input format based on file extension""" -function detect_informat(source::AbstractString) - ext = lowercase(splitext(source)[2]) - - ext == ".jl" && return "script" - ext == ".jmd" && return "markdown" - ext == ".ipynb" && return "notebook" - return "noweb" -end - -"""Read and parse input document""" -function read_doc(source::AbstractString, format = :auto) - format === :auto && (format = detect_informat(source)) - document = read(source, String) - document = replace(document, "\r\n" => "\n") - parsed = parse_doc(document, format) - header = parse_header(parsed[1]) - doc = WeaveDoc(source, parsed, header) - haskey(header, "options") && header_chunk_defaults!(doc) - return doc -end - -function parse_header(chunk::CodeChunk) - return Dict() -end - -const HEADER_REGEX = r"^---$(?
((?!---).)+)^---$"ms - -function parse_header(chunk::DocChunk) - m = match(HEADER_REGEX, chunk.content[1].content) - if m !== nothing - header = YAML.load(string(m[:header])) - else - header = Dict() - end - return header -end - -parse_doc(document::AbstractString, format::AbstractString = "noweb") = - parse_doc(document, input_formats[format]) - -"""Parse documents with Weave.jl markup""" -function parse_doc(document::AbstractString, format::MarkupInput) - document = replace(document, "\r\n" => "\n") - lines = split(document, "\n") - - codestart = format.codestart - codeend = format.codeend - state = "doc" - - docno = 1 - codeno = 1 - content = "" - start_line = 0 - - options = Dict() - optionString = "" - parsed = Any[] - for lineno = 1:length(lines) - line = lines[lineno] - if (m = match(codestart, line)) != nothing && state == "doc" - state = "code" - if m.captures[1] == nothing - optionString = "" - else - optionString = strip(m.captures[1]) - end - - options = Dict{Symbol,Any}() - if length(optionString) > 0 - expr = Meta.parse(optionString) - Base.Meta.isexpr(expr, :(=)) && (options[expr.args[1]] = expr.args[2]) - Base.Meta.isexpr(expr, :toplevel) && - map(pushopt, fill(options, length(expr.args)), expr.args) - end - haskey(options, :label) && (options[:name] = options[:label]) - haskey(options, :name) || (options[:name] = nothing) - - if !isempty(strip(content)) - chunk = DocChunk(content, docno, start_line, format.inline) - docno += 1 - push!(parsed, chunk) - end - - content = "" - start_line = lineno - - continue - - end - if occursin(codeend, line) && state == "code" - - chunk = CodeChunk(content, codeno, start_line, optionString, options) - - codeno += 1 - start_line = lineno - content = "" - state = "doc" - push!(parsed, chunk) - continue - end - - if lineno == 1 - content *= line - else - content *= "\n" * line - end - end - - # Remember the last chunk - if strip(content) != "" - chunk = DocChunk(content, docno, start_line, format.inline) - # chunk = Dict{Symbol,Any}(:type => "doc", :content => content, - # :number => docno, :start_line => start_line) - push!(parsed, chunk) - end - return parsed -end - -"""Parse .jl scripts with Weave.jl markup""" -function parse_doc(document::AbstractString, format::ScriptInput) - document = replace(document, "\r\n" => "\n") - lines = split(document, "\n") - - doc_line = format.doc_line - doc_start = format.doc_start - opt_line = format.opt_line - opt_start = format.opt_start - - read = "" - chunks = [] - docno = 1 - codeno = 1 - content = "" - start_line = 1 - options = Dict{Symbol,Any}() - optionString = "" - parsed = Any[] - state = "code" - lineno = 1 - n_emptylines = 0 - - for lineno = 1:length(lines) - line = lines[lineno] - if (m = match(doc_line, line)) != nothing && (m = match(opt_line, line)) == nothing - line = replace(line, doc_start => "", count = 1) - if startswith(line, " ") - line = replace(line, " " => "", count = 1) - end - if state == "code" && strip(read) != "" - chunk = - CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) - push!(parsed, chunk) - codeno += 1 - read = "" - start_line = lineno - end - state = "doc" - elseif (m = match(opt_line, line)) != nothing - start_line = lineno - if state == "code" && strip(read) != "" - chunk = - CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) - push!(parsed, chunk) - read = "" - codeno += 1 - end - if state == "doc" && strip(read) != "" - (docno > 1) && (read = "\n" * read) # Add whitespace to doc chunk. Needed for markdown output - chunk = DocChunk(read, docno, start_line) - push!(parsed, chunk) - read = "" - docno += 1 - end - - optionString = replace(line, opt_start => "", count = 1) - # Get options - options = Dict{Symbol,Any}() - if length(optionString) > 0 - expr = Meta.parse(optionString) - Base.Meta.isexpr(expr, :(=)) && (options[expr.args[1]] = expr.args[2]) - Base.Meta.isexpr(expr, :toplevel) && - map(pushopt, fill(options, length(expr.args)), expr.args) - end - haskey(options, :label) && (options[:name] = options[:label]) - haskey(options, :name) || (options[:name] = nothing) - - state = "code" - continue - elseif state == "doc" # && strip(line) != "" && strip(read) != "" - state = "code" - (docno > 1) && (read = "\n" * read) # Add whitespace to doc chunk. Needed for markdown output - chunk = DocChunk(read, docno, start_line, format.inline) - push!(parsed, chunk) - options = Dict{Symbol,Any}() - start_line = lineno - read = "" - docno += 1 - end - read *= line * "\n" - - if strip(line) == "" - n_emptylines += 1 - else - n_emptylines = 0 - end - end - - # Handle the last chunk - if state == "code" - chunk = CodeChunk("\n" * strip(read), codeno, start_line, optionString, options) - push!(parsed, chunk) - else - chunk = DocChunk(read, docno, start_line, format.inline) - push!(parsed, chunk) - end - - return parsed -end - -"""Parse IJUlia notebook""" -function parse_doc(document::String, format::NotebookInput) - document = replace(document, "\r\n" => "\n") - nb = JSON.parse(document) - parsed = Any[] - options = Dict{Symbol,Any}() - opt_string = "" - docno = 1 - codeno = 1 - - for cell in nb["cells"] - srctext = "\n" * join(cell["source"], "") - - if cell["cell_type"] == "code" - chunk = CodeChunk(rstrip(srctext), codeno, 0, opt_string, options) - push!(parsed, chunk) - codeno += 1 - else - chunk = DocChunk(srctext * "\n", docno, 0) - push!(parsed, chunk) - docno += 1 - end - end - - return parsed -end - -# Use this if regex is undefined -function parse_inline(text, noex) - return Inline[InlineText(text, 1, length(text), 1)] -end - -function parse_inline(text::AbstractString, inline_ex::Regex) - occursin(inline_ex, text) || return Inline[InlineText(text, 1, length(text), 1)] - - inline_chunks = eachmatch(inline_ex, text) - s = 1 - e = 1 - res = Inline[] - textno = 1 - codeno = 1 - - for ic in inline_chunks - s = ic.offset - doc = InlineText(text[e:(s-1)], e, s - 1, textno) - textno += 1 - push!(res, doc) - e = s + lastindex(ic.match) - ic.captures[1] !== nothing && (ctype = :inline) - ic.captures[2] !== nothing && (ctype = :line) - cap = filter(x -> x !== nothing, ic.captures)[1] - push!(res, InlineCode(cap, s, e, codeno, ctype)) - codeno += 1 - end - push!(res, InlineText(text[e:end], e, length(text), textno)) - - return res -end diff --git a/test/errors_test.jl b/test/errors_test.jl index c0d4162..b128ad5 100644 --- a/test/errors_test.jl +++ b/test/errors_test.jl @@ -1,7 +1,3 @@ -using Weave -using Weave: run_doc -using Test - s1= """ ```julia @@ -21,12 +17,10 @@ print(y """ -p1 = Weave.parse_doc(s1, "markdown") +p1 = Weave.parse_markdown(s1) doc = Weave.WeaveDoc("dummy1.jmd", p1, Dict()) doc1 = run_doc(doc, doctype = "pandoc") -doc1.chunks[1].output - @test doc1.chunks[1].output == "Error: ArgumentError: Package NonExisting not found in current path:\n- Run `import Pkg; Pkg.add(\"NonExisting\")` to install the NonExisting package.\n\n" @test doc1.chunks[2].output == "Error: syntax: incomplete: premature end of input\n" @test doc1.chunks[3].output == "\njulia> plot(x)\nError: UndefVarError: plot not defined\n\njulia> y = 10\n10\n\njulia> print(y\nError: syntax: incomplete: premature end of input\n" diff --git a/test/formatter_test.jl b/test/formatter_test.jl index e2b559c..633f578 100644 --- a/test/formatter_test.jl +++ b/test/formatter_test.jl @@ -1,7 +1,3 @@ -using Weave -using Weave: run_doc -using Test - # Test rendering of doc chunks content = """ # Test chunk @@ -132,7 +128,7 @@ f = Weave.format_chunk(dchunk, pformat.formatdict, pformat) @test f == "\\section{Test chunk}\nα\n\n" function doc_from_string(str) - parsed = Weave.parse_doc(str,"markdown") + parsed = Weave.parse_markdown(str) header = Weave.parse_header(parsed[1]) Weave.WeaveDoc("",parsed,header) end diff --git a/test/inline_test.jl b/test/inline_test.jl index 3782972..54e68b3 100644 --- a/test/inline_test.jl +++ b/test/inline_test.jl @@ -1,4 +1,3 @@ -using Weave, Test using Mustache # Test parsing @@ -13,19 +12,18 @@ Some markdown with inline stuff and `j code` """ -pat = Weave.input_formats["markdown"].inline -ms = collect(eachmatch(pat, doc)) +ms = collect(eachmatch(Weave.INLINE_REGEX, doc)) @test ms[1][2] == "println(\"Something\")" @test ms[2][1] == "code" @test ms[3][1] == "show(\"is\")" -chunk = Weave.parse_doc(doc, Weave.input_formats["markdown"])[1] +chunk = Weave.parse_markdown(doc)[1] @test length(chunk.content) == 7 @test chunk.content[2].content == ms[1][2] @test chunk.content[4].content == ms[2][1] @test chunk.content[6].content == ms[3][1] -chunknw = Weave.parse_doc(doc, Weave.input_formats["noweb"])[1] +chunknw = Weave.parse_markdown(doc, false)[1] @test all([chunknw.content[i].content == chunk.content[i].content for i in 1:7]) # Test with document diff --git a/test/test_module_evaluation.jl b/test/test_module_evaluation.jl index 4e2f101..73fefd7 100644 --- a/test/test_module_evaluation.jl +++ b/test/test_module_evaluation.jl @@ -1,6 +1,6 @@ @testset "evaluation module" begin function mock_output(document, mod = nothing) - parsed = Weave.parse_doc(document, "markdown") + parsed = Weave.parse_markdown(document) doc = Weave.WeaveDoc("dummy.jmd", parsed, Dict()) result_doc = run_doc(doc, mod = mod) @test isdefined(result_doc.chunks[1], :output)