<commit_before>"""
Provides the [`doctest`](@ref) function that makes sure that the `jldoctest` code blocks
in the documents and docstrings run and are up to date.
"""
module DocTests
using DocStringExtensions

import ..Documenter:
    DocSystem,
    DocMeta,
    Documenter,
    Documents,
    Expanders,
    Utilities,
    IdDict

import REPL, Markdown, MarkdownAST
using AbstractTrees: Leaves
import .Utilities: @docerror
import IOCapture

# Julia code block testing.
# -------------------------

mutable struct MutableMD2CodeBlock
    language :: String
    code :: String
end
MutableMD2CodeBlock(block :: MarkdownAST.CodeBlock) = MutableMD2CodeBlock(block.info, block.code)

struct DocTestContext
    file :: String
    doc :: Documents.Document
    meta :: Dict{Symbol, Any}
    DocTestContext(file::String, doc::Documents.Document) = new(file, doc, Dict())
end

"""
$(SIGNATURES)

Traverses the pages and modules in the documenter blueprint, searching and
executing doctests.

Will abort the document generation when an error is thrown. Use `doctest = false`
keyword in [`Documenter.makedocs`](@ref) to disable doctesting.
"""
function doctest(blueprint::Documents.DocumentBlueprint, doc::Documents.Document)
    @debug "Running doctests."
    # find all the doctest blocks in the pages
    for (src, page) in blueprint.pages
        if Utilities.is_draft(doc, page)
            @debug "Skipping page-doctests in draft mode" page.source
            continue
        end
        doctest(page, doc)
    end

    if Utilities.is_draft(doc)
        @debug "Skipping docstring-doctests in draft mode"
        return
    end

    # find all the doctest block in all the docstrings (within specified modules)
    for mod in blueprint.modules
        for (binding, multidoc) in DocSystem.getmeta(mod)
            for signature in multidoc.order
                doctest(multidoc.docs[signature], mod, doc)
            end
        end
    end
end

function doctest(page::Documents.Page, doc::Documents.Document)
    ctx = DocTestContext(page.source, doc) # FIXME
    ctx.meta[:CurrentFile] = page.source
    doctest(ctx, page.mdast)
end

function doctest(docstr::Docs.DocStr, mod::Module, doc::Documents.Document)
    md = DocSystem.parsedoc(docstr)
    # Note: parsedocs / formatdoc in Base is weird. It double-wraps the docstring Markdown
    # in a Markdown.MD object..
    @assert isa(md, Markdown.MD) # relying on Julia internals here
    while length(md.content) == 1 && isa(first(md.content), Markdown.MD)
        md = first(md.content)
    end
    mdast = try
        convert(MarkdownAST.Node, md)
    catch err
        @error """
            MarkdownAST conversion error for a docstring in $(mod).
            This is a bug — please report this on the Documenter issue tracker
            """ docstr.data
        rethrow(err)
    end
    ctx = DocTestContext(docstr.data[:path], doc)
    merge!(ctx.meta, DocMeta.getdocmeta(mod))
    ctx.meta[:CurrentFile] = get(docstr.data, :path, nothing)
    doctest(ctx, mdast)
end

function parse_metablock(ctx::DocTestContext, block::MarkdownAST.CodeBlock)
    @assert startswith(block.info, "@meta")
    meta = Dict{Symbol, Any}()
    for (ex, str) in Utilities.parseblock(block.code, ctx.doc, ctx.file)
        if Utilities.isassign(ex)
            try
                meta[ex.args[1]] = Core.eval(Main, ex.args[2])
            catch err
                @docerror(ctx.doc, :meta_block, "Failed to evaluate `$(strip(str))` in `@meta` block.", exception = err)
            end
        end
    end
    return meta
end

function doctest(ctx::DocTestContext, mdast::MarkdownAST.Node)
    for node in Leaves(mdast)
        isa(node.element, MarkdownAST.CodeBlock) || continue
        if startswith(node.element.info, "jldoctest")
            doctest(ctx, node.element)
        elseif startswith(node.element.info, "@meta")
            merge!(ctx.meta, parse_metablock(ctx, node.element))
        end
    end
end

function doctest(ctx::DocTestContext, block_immutable::MarkdownAST.CodeBlock)
    lang = block_immutable.info
    if startswith(lang, "jldoctest")
        # Define new module or reuse an old one from this page if we have a named doctest.
        name = match(r"jldoctest[ ]?(.*)$", split(lang, ';', limit = 2)[1])[1]
        sandbox = Utilities.get_sandbox_module!(ctx.meta, "doctest", name)

        # Normalise line endings.
        block = MutableMD2CodeBlock(block_immutable)
        block.code = replace(block.code, "\r\n" => "\n")

        # parse keyword arguments to doctest
        d = Dict()
        idx = findfirst(c -> c == ';', lang)
        if idx !== nothing
            kwargs = try
                Meta.parse("($(lang[nextind(lang, idx):end]),)")
            catch e
                e isa Meta.ParseError || rethrow(e)
                file = ctx.meta[:CurrentFile]
                lines = Utilities.find_block_in_file(block.code, file)
                @docerror(ctx.doc, :doctest,
                    """
                    Unable to parse doctest keyword arguments in $(Utilities.locrepr(file, lines))
                    Use ```jldoctest name; key1 = value1, key2 = value2

                    ```$(lang)
                    $(block.code)
                    ```
                    """, parse_error = e)
                return false
            end
            for kwarg in kwargs.args
                if !(isa(kwarg, Expr) && kwarg.head === :(=) && isa(kwarg.args[1], Symbol))
                    file = ctx.meta[:CurrentFile]
                    lines = Utilities.find_block_in_file(block.code, file)
                    @docerror(ctx.doc, :doctest,
                        """
                        invalid syntax for doctest keyword arguments in $(Utilities.locrepr(file, lines))
                        Use ```jldoctest name; key1 = value1, key2 = value2

                        ```$(lang)
                        $(block.code)
                        ```
                        """)
                    return false
                end
                d[kwarg.args[1]] = Core.eval(sandbox, kwarg.args[2])
            end
        end
        ctx.meta[:LocalDocTestArguments] = d

        for expr in [get(ctx.meta, :DocTestSetup, []); get(ctx.meta[:LocalDocTestArguments], :setup, [])]
            Meta.isexpr(expr, :block) && (expr.head = :toplevel)
            try
                Core.eval(sandbox, expr)
            catch e
                push!(ctx.doc.internal.errors, :doctest)
                @error("could not evaluate expression from doctest setup.",
                    expression = expr, exception = e)
                return false
            end
        end
        if occursin(r"^julia> "m, block.code)
            eval_repl(block, sandbox, ctx.meta, ctx.doc, ctx.file)
        elseif occursin(r"^# output$"m, block.code)
            eval_script(block, sandbox, ctx.meta, ctx.doc, ctx.file)
        else
            file = ctx.meta[:CurrentFile]
            lines = Utilities.find_block_in_file(block.code, file)
            @docerror(ctx.doc, :doctest,
                """
                invalid doctest block in $(Utilities.locrepr(file, lines))
                Requires `julia> ` or `# output`<commit_msg>Issue a more helpful warning if #output contains trailing whitespace in doctest (#1940)<commit_after>"""
Provides the [`doctest`](@ref) function that makes sure that the `jldoctest` code blocks
in the documents and docstrings run and are up to date.
"""
module DocTests
using DocStringExtensions

import ..Documenter:
    DocSystem,
    DocMeta,
    Documenter,
    Documents,
    Expanders,
    Utilities,
    IdDict

import REPL, Markdown, MarkdownAST
using AbstractTrees: Leaves
import .Utilities: @docerror
import IOCapture

# Julia code block testing.
# -------------------------

mutable struct MutableMD2CodeBlock
    language :: String
    code :: String
end
MutableMD2CodeBlock(block :: MarkdownAST.CodeBlock) = MutableMD2CodeBlock(block.info, block.code)

struct DocTestContext
    file :: String
    doc :: Documents.Document
    meta :: Dict{Symbol, Any}
    DocTestContext(file::String, doc::Documents.Document) = new(file, doc, Dict())
end

"""
$(SIGNATURES)

Traverses the pages and modules in the documenter blueprint, searching and
executing doctests.

Will abort the document generation when an error is thrown. Use `doctest = false`
keyword in [`Documenter.makedocs`](@ref) to disable doctesting.
"""
function doctest(blueprint::Documents.DocumentBlueprint, doc::Documents.Document)
    @debug "Running doctests."
    # find all the doctest blocks in the pages
    for (src, page) in blueprint.pages
        if Utilities.is_draft(doc, page)
            @debug "Skipping page-doctests in draft mode" page.source
            continue
        end
        doctest(page, doc)
    end

    if Utilities.is_draft(doc)
        @debug "Skipping docstring-doctests in draft mode"
        return
    end

    # find all the doctest block in all the docstrings (within specified modules)
    for mod in blueprint.modules
        for (binding, multidoc) in DocSystem.getmeta(mod)
            for signature in multidoc.order
                doctest(multidoc.docs[signature], mod, doc)
            end
        end
    end
end

function doctest(page::Documents.Page, doc::Documents.Document)
    ctx = DocTestContext(page.source, doc) # FIXME
    ctx.meta[:CurrentFile] = page.source
    doctest(ctx, page.mdast)
end

function doctest(docstr::Docs.DocStr, mod::Module, doc::Documents.Document)
    md = DocSystem.parsedoc(docstr)
    # Note: parsedocs / formatdoc in Base is weird. It double-wraps the docstring Markdown
    # in a Markdown.MD object..
    @assert isa(md, Markdown.MD) # relying on Julia internals here
    while length(md.content) == 1 && isa(first(md.content), Markdown.MD)
        md = first(md.content)
    end
    mdast = try
        convert(MarkdownAST.Node, md)
    catch err
        @error """
            MarkdownAST conversion error for a docstring in $(mod).
            This is a bug — please report this on the Documenter issue tracker
            """ docstr.data
        rethrow(err)
    end
    ctx = DocTestContext(docstr.data[:path], doc)
    merge!(ctx.meta, DocMeta.getdocmeta(mod))
    ctx.meta[:CurrentFile] = get(docstr.data, :path, nothing)
    doctest(ctx, mdast)
end

function parse_metablock(ctx::DocTestContext, block::MarkdownAST.CodeBlock)
    @assert startswith(block.info, "@meta")
    meta = Dict{Symbol, Any}()
    for (ex, str) in Utilities.parseblock(block.code, ctx.doc, ctx.file)
        if Utilities.isassign(ex)
            try
                meta[ex.args[1]] = Core.eval(Main, ex.args[2])
            catch err
                @docerror(ctx.doc, :meta_block, "Failed to evaluate `$(strip(str))` in `@meta` block.", exception = err)
            end
        end
    end
    return meta
end

function doctest(ctx::DocTestContext, mdast::MarkdownAST.Node)
    for node in Leaves(mdast)
        isa(node.element, MarkdownAST.CodeBlock) || continue
        if startswith(node.element.info, "jldoctest")
            doctest(ctx, node.element)
        elseif startswith(node.element.info, "@meta")
            merge!(ctx.meta, parse_metablock(ctx, node.element))
        end
    end
end

function doctest(ctx::DocTestContext, block_immutable::MarkdownAST.CodeBlock)
    lang = block_immutable.info
    if startswith(lang, "jldoctest")
        # Define new module or reuse an old one from this page if we have a named doctest.
        name = match(r"jldoctest[ ]?(.*)$", split(lang, ';', limit = 2)[1])[1]
        sandbox = Utilities.get_sandbox_module!(ctx.meta, "doctest", name)

        # Normalise line endings.
        block = MutableMD2CodeBlock(block_immutable)
        block.code = replace(block.code, "\r\n" => "\n")

        # parse keyword arguments to doctest
        d = Dict()
        idx = findfirst(c -> c == ';', lang)
        if idx !== nothing
            kwargs = try
                Meta.parse("($(lang[nextind(lang, idx):end]),)")
            catch e
                e isa Meta.ParseError || rethrow(e)
                file = ctx.meta[:CurrentFile]
                lines = Utilities.find_block_in_file(block.code, file)
                @docerror(ctx.doc, :doctest,
                    """
                    Unable to parse doctest keyword arguments in $(Utilities.locrepr(file, lines))
                    Use ```jldoctest name; key1 = value1, key2 = value2

                    ```$(lang)
                    $(block.code)
                    ```
                    """, parse_error = e)
                return false
            end
            for kwarg in kwargs.args
                if !(isa(kwarg, Expr) && kwarg.head === :(=) && isa(kwarg.args[1], Symbol))
                    file = ctx.meta[:CurrentFile]
                    lines = Utilities.find_block_in_file(block.code, file)
                    @docerror(ctx.doc, :doctest,
                        """
                        invalid syntax for doctest keyword arguments in $(Utilities.locrepr(file, lines))
                        Use ```jldoctest name; key1 = value1, key2 = value2

                        ```$(lang)
                        $(block.code)
                        ```
                        """)
                    return false
                end
                d[kwarg.args[1]] = Core.eval(sandbox, kwarg.args[2])
            end
        end
        ctx.meta[:LocalDocTestArguments] = d

        for expr in [get(ctx.meta, :DocTestSetup, []); get(ctx.meta[:LocalDocTestArguments], :setup, [])]
            Meta.isexpr(expr, :block) && (expr.head = :toplevel)
            try
                Core.eval(sandbox, expr)
            catch e
                push!(ctx.doc.internal.errors, :doctest)
                @error("could not evaluate expression from doctest setup.",
                    expression = expr, exception = e)
                return false
            end
        end
        if occursin(r"^julia> "m, block.code)
            eval_repl(block, sandbox, ctx.meta, ctx.doc, ctx.file)
        elseif occursin(r"^# output$"m, block.code)
            eval_script(block, sandbox, ctx.meta, ctx.doc, ctx.file)
        elseif occursin(r"^# output\s+$"m, block.code)
            file = ctx.meta[:CurrentFile]
            lines = Utilities.find_block_in_file(block.code, file)
            @docerror(ctx.doc, :doctest,
                """
                invalid doctest block in $(Utilities.locrepr(file, lines))
                Requires `# output` without trailing whitespace

                ```$(lang)
                $(block.code)
                ```
                """)
        else
            file = ctx.meta[:CurrentFile]
            lines = Utilities.find_block_in_file(block.code, file)
            @docerror(ctx.doc, :doctest,
                """
                invalid doctest block in $(Utilities.locrepr(file, lines))
                Requires `julia> ` or `# output`
{'fraction_non_alphanumeric': 0.1119358074222668, 'fraction_numerical': 0.003075894349715814, 'mean_word_length': 2.100331674958541, 'pattern_counts': {'":': 0, '<': 3, '<?xml version=': 0, '>': 11, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3573766', 'n_tokens_mistral': 4497, 'n_tokens_neox': 4349, 'n_words': 1151}