Skip to content

Commit

Permalink
Add a prefix when replacing for doctest=:fix (#2378)
Browse files Browse the repository at this point in the history
  • Loading branch information
lkastner authored May 1, 2024
1 parent 478bb38 commit 49f80af
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 23 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

* In HTML output, links in inline code are now correctly colored on hover. ([#2497])
* Doctest fixing functionality handles another edge case. ([#2303], [#2378])

## Version [v1.4.0] - 2024-04-14

Expand Down Expand Up @@ -1789,6 +1790,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2288]: https://github.com/JuliaDocs/Documenter.jl/issues/2288
[#2293]: https://github.com/JuliaDocs/Documenter.jl/issues/2293
[#2300]: https://github.com/JuliaDocs/Documenter.jl/issues/2300
[#2303]: https://github.com/JuliaDocs/Documenter.jl/issues/2303
[#2306]: https://github.com/JuliaDocs/Documenter.jl/issues/2306
[#2307]: https://github.com/JuliaDocs/Documenter.jl/issues/2307
[#2308]: https://github.com/JuliaDocs/Documenter.jl/issues/2308
Expand All @@ -1810,6 +1812,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2373]: https://github.com/JuliaDocs/Documenter.jl/issues/2373
[#2374]: https://github.com/JuliaDocs/Documenter.jl/issues/2374
[#2375]: https://github.com/JuliaDocs/Documenter.jl/issues/2375
[#2378]: https://github.com/JuliaDocs/Documenter.jl/issues/2378
[#2394]: https://github.com/JuliaDocs/Documenter.jl/issues/2394
[#2406]: https://github.com/JuliaDocs/Documenter.jl/issues/2406
[#2408]: https://github.com/JuliaDocs/Documenter.jl/issues/2408
Expand Down
90 changes: 69 additions & 21 deletions src/doctests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ function _doctest(blueprint::Documenter.DocumentBlueprint, doc::Documenter.Docum
end
end

mutable struct MutablePrefix
content :: String
MutablePrefix() = new("")
end

function _doctest(page::Documenter.Page, doc::Documenter.Document)
ctx = DocTestContext(page.source, doc) # FIXME
ctx.meta[:CurrentFile] = page.source
Expand Down Expand Up @@ -202,24 +207,31 @@ _doctest(ctx::DocTestContext, block) = true
# Doctest evaluation.

mutable struct Result
block :: MutableMD2CodeBlock # The entire code block that is being tested.
input :: String # Part of `block.code` representing the current input.
output :: String # Part of `block.code` representing the current expected output.
file :: String # File in which the doctest is written. Either `.md` or `.jl`.
value :: Any # The value returned when evaluating `input`.
hide :: Bool # Semi-colon suppressing the output?
stdout :: IOBuffer # Redirected stdout/stderr gets sent here.
bt :: Vector # Backtrace when an error is thrown.
block :: MutableMD2CodeBlock # The entire code block that is being tested.
raw_input :: String # Part of `block.code` representing the current input.
input :: String # Part of `block.code` representing the current input
# without leading repl prompts and spaces.
output :: String # Part of `block.code` representing the current expected output.
file :: String # File in which the doctest is written. Either `.md` or `.jl`.
value :: Any # The value returned when evaluating `input`.
hide :: Bool # Semi-colon suppressing the output?
stdout :: IOBuffer # Redirected stdout/stderr gets sent here.
bt :: Vector # Backtrace when an error is thrown.

function Result(block, input, output, file)
new(block, input, rstrip(output, '\n'), file, nothing, false, IOBuffer())
new(block, input, input, rstrip(output, '\n'), file, nothing, false, IOBuffer())
end
function Result(block, raw_input, input, output, file)
new(block, raw_input, input, rstrip(output, '\n'), file, nothing, false, IOBuffer())
end
end


function eval_repl(block, sandbox, meta::Dict, doc::Documenter.Document, page)
src_lines = Documenter.find_block_in_file(block.code, meta[:CurrentFile])
for (input, output) in repl_splitter(block.code)
result = Result(block, input, output, meta[:CurrentFile])
(prefix, split) = repl_splitter(block.code)
for (raw_input, input, output) in split
result = Result(block, raw_input, input, output, meta[:CurrentFile])
for (ex, str) in Documenter.parseblock(input, doc, page; keywords = false, raise=false)
# Input containing a semi-colon gets suppressed in the final output.
@debug "Evaluating REPL line from doctest at $(Documenter.locrepr(result.file, src_lines))" unparsed_string = str parsed_expression = ex
Expand All @@ -239,7 +251,7 @@ function eval_repl(block, sandbox, meta::Dict, doc::Documenter.Document, page)
# don't evaluate further if there is a parse error
isa(ex, Expr) && ex.head === :error && break
end
checkresult(sandbox, result, meta, doc)
checkresult(sandbox, result, meta, doc; prefix)
end
end

Expand Down Expand Up @@ -288,7 +300,7 @@ function filter_doctests(filters, strings)
end

# Regex used here to replace gensym'd module names could probably use improvements.
function checkresult(sandbox::Module, result::Result, meta::Dict, doc::Documenter.Document)
function checkresult(sandbox::Module, result::Result, meta::Dict, doc::Documenter.Document; prefix::MutablePrefix=MutablePrefix())
sandbox_name = nameof(sandbox)
mod_regex = Regex("(Main\\.)?(Symbol\\(\"$(sandbox_name)\"\\)|$(sandbox_name))[,.]")
mod_regex_nodot = Regex(("(Main\\.)?$(sandbox_name)"))
Expand All @@ -310,14 +322,24 @@ function checkresult(sandbox::Module, result::Result, meta::Dict, doc::Documente
)
# Since checking for the prefix of an error won't catch the empty case we need
# to check that manually with `isempty`.
if isempty(head) || !startswith(filteredstr, filteredhead)
# In the doc.user.doctest === :fix we need actual equality of
# filteredstr and filteredhead, otherwise this needs to get repaired.
if isempty(head) || !startswith(filteredstr, filteredhead) ||
(doc.user.doctest === :fix && filteredstr != filteredhead)
if doc.user.doctest === :fix
fix_doctest(result, str, doc)
fix_doctest(result, str, doc; prefix)
else
report(result, str, doc)
@debug "Doctest metadata" meta
push!(doc.internal.errors, :doctest)
end
else
# Prefix was not modified, unless output was different.
prefix.content *= result.raw_input*"\n"
if str != ""
prefix.content *= str * "\n"
end
prefix.content *= "\n"
end
else
value = result.hide ? nothing : result.value # `;` hides output.
Expand All @@ -332,12 +354,19 @@ function checkresult(sandbox::Module, result::Result, meta::Dict, doc::Documente
)
if filteredstr != filteredoutput
if doc.user.doctest === :fix
fix_doctest(result, str, doc)
fix_doctest(result, str, doc; prefix)
else
report(result, str, doc)
@debug "Doctest metadata" meta
push!(doc.internal.errors, :doctest)
end
else
# Prefix was not modified, unless output was different.
prefix.content *= result.raw_input*"\n"
if str != ""
prefix.content *= str * "\n"
end
prefix.content *= "\n"
end
end
return nothing
Expand Down Expand Up @@ -448,7 +477,7 @@ function report(result::Result, str, doc::Documenter.Document)
""", diff, _file=result.file, _line=line)
end

function fix_doctest(result::Result, str, doc::Documenter.Document)
function fix_doctest(result::Result, str, doc::Documenter.Document; prefix::MutablePrefix=MutablePrefix())
code = result.block.code
filename = Base.find_source_file(result.file)
# read the file containing the code block
Expand All @@ -470,7 +499,8 @@ function fix_doctest(result::Result, str, doc::Documenter.Document)
write(io, content[1:prevind(content, first(codeidx))])
# next look for the particular input string in the given code block
# make a regex of the input that matches leading whitespace (for multiline input)
rinput = "\\h*" * replace(Documenter.regex_escape(result.input), "\\n" => "\\n\\h*")
composed = prefix.content * result.raw_input
rinput = replace(Documenter.regex_escape(composed), "\\n" => "\\n\\h*")
r = Regex(rinput)
inputidx = findfirst(r, code)
if inputidx === nothing
Expand All @@ -480,6 +510,11 @@ function fix_doctest(result::Result, str, doc::Documenter.Document)
# construct the new code-snippet (without indent)
# first part: everything up until the last index of the input string
newcode = code[1:last(inputidx)]
prefix.content = newcode * "\n"
if str != ""
prefix.content *= str * "\n"
end
prefix.content *= "\n"
isempty(result.output) && (newcode *= '\n') # issue #772
# second part: the rest, with the old output replaced with the new one
if result.output == ""
Expand All @@ -488,7 +523,11 @@ function fix_doctest(result::Result, str, doc::Documenter.Document)
newcode *= str
newcode *= code[nextind(code, last(inputidx)):end]
else
newcode *= replace(code[nextind(code, last(inputidx)):end], result.output => str, count = 1)
if str == ""
newcode *= replace(code[nextind(code, last(inputidx)):end], result.output * "\n" => str, count = 1)
else
newcode *= replace(code[nextind(code, last(inputidx)):end], result.output => str, count = 1)
end
end
# replace internal code block with the non-indented new code, needed if we come back
# looking to replace output in the same code block later
Expand All @@ -511,31 +550,40 @@ const SOURCE_REGEX = r"^ (.*)$"
function repl_splitter(code)
lines = split(string(code, "\n"), '\n')
input = String[]
raw_inputs = String[]
output = String[]
prefix = MutablePrefix()
buffer = IOBuffer() # temporary buffer for doctest inputs and outputs
raw_input_buffer = IOBuffer()
found_first_prompt = false
while !isempty(lines)
line = popfirst!(lines)
prompt = match(PROMPT_REGEX, line)
# We allow comments before the first julia> prompt
!found_first_prompt && startswith(line, '#') && continue
if !found_first_prompt && startswith(line, '#')
prefix.content *= line * "\n"
continue
end
if prompt === nothing
source = match(SOURCE_REGEX, line)
if source === nothing
savebuffer!(input, buffer)
savebuffer!(raw_inputs, raw_input_buffer)
println(buffer, line)
takeuntil!(PROMPT_REGEX, buffer, lines)
else
println(buffer, source[1])
println(raw_input_buffer, line)
end
else
found_first_prompt = true
savebuffer!(output, buffer)
println(buffer, prompt[1])
println(raw_input_buffer, line)
end
end
savebuffer!(output, buffer)
zip(input, output)
return prefix, zip(raw_inputs, input, output)
end

function savebuffer!(out, buf)
Expand Down
37 changes: 37 additions & 0 deletions test/doctests/fix/broken.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,40 @@ julia> 1 + 2
julia> 3 + 4
```
```jldoctest
julia> a = (1,2)
julia> a
```
```jldoctest
# Leading comment
julia> a
ERROR: UndefVarError: `a` not defined
julia> a = Int64[1,2]
2-element Vector{Int64}:
1
2
julia> b
julia> a
2-element Vector{Int64}:
2
julia> a;
1
julia> b;
julia> a = Int64[3,4];
julia> a
3
4
```
```jldoctest
julia> a = ("a", "b", "c");
julia> a
```
46 changes: 46 additions & 0 deletions test/doctests/fix/fixed.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,49 @@ julia> 1 + 2
julia> 3 + 4
7
```
```jldoctest
julia> a = (1,2)
(1, 2)
julia> a
(1, 2)
```
```jldoctest
# Leading comment
julia> a
ERROR: UndefVarError: `a` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
julia> a = Int64[1,2]
2-element Vector{Int64}:
1
2
julia> b
ERROR: UndefVarError: `b` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
julia> a
2-element Vector{Int64}:
1
2
julia> a;
julia> b;
ERROR: UndefVarError: `b` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
julia> a = Int64[3,4];
julia> a
2-element Vector{Int64}:
3
4
```
```jldoctest
julia> a = ("a", "b", "c");
julia> a
("a", "b", "c")
```
20 changes: 18 additions & 2 deletions test/doctests/fix/tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,24 @@ function test_doctest_fix(dir)
@debug "Running doctest/fix doctests with doctest=true"
@quietly makedocs(sitename="-", modules = [Foo], source = srcdir, build = builddir)

# also test that we obtain the expected output
@test normalize_line_endings(index_md) == normalize_line_endings(joinpath(@__DIR__, "fixed.md"))
# Load the expected results and adapt to various Julia versions:
md_result = normalize_line_endings(joinpath(@__DIR__, "fixed.md"))
if VERSION < v"1.12-DEV"
# 1.12 Starts printing "in `Main`", so we remove that from the expected output.
md_result = replace(md_result, r"UndefVarError: `([^`]*)` not defined in `Main`" => s"UndefVarError: `\1` not defined")
end
if VERSION < v"1.11"
# 1.11 started printing the 'Suggestion: check for spelling errors or missing imports.' messages
# for UndefVarError, so we remove them from the expected output.
md_result = replace(md_result, r"UndefVarError: `([^`]*)` not defined\nSuggestion: .+" => s"UndefVarError: `\1` not defined")
end
if VERSION < v"1.7"
# The UndefVarError prints the backticks around the variable name in 1.7+, so we need to remove them.
md_result = replace(md_result, r"UndefVarError: `([^`]*)` not defined" => s"UndefVarError: \1 not defined")
end

# test that we obtain the expected output
@test normalize_line_endings(index_md) == md_result
@test normalize_line_endings(src_jl) == normalize_line_endings(joinpath(@__DIR__, "fixed.jl"))
end

Expand Down

0 comments on commit 49f80af

Please sign in to comment.