From d273c53e5cc896595b608ecb154e8f234f05dc5a Mon Sep 17 00:00:00 2001 From: Morten Piibeleht Date: Thu, 4 Jul 2024 16:03:44 +1200 Subject: [PATCH] lots of wip --- src/CodeEvaluation.jl | 3 + src/codeblock.jl | 10 ++ src/parseblock.jl | 87 +++++++++++++++++ src/replblock.jl | 15 +++ src/sandbox.jl | 211 ++++++++++++------------------------------ test/codeblock.jl | 113 ++++++++++++++++++++++ test/parseblock.jl | 67 ++++++++++++++ test/runtests.jl | 112 ++-------------------- test/sandbox.jl | 169 +++++++++++++++++++++++++++++++++ 9 files changed, 527 insertions(+), 260 deletions(-) create mode 100644 src/codeblock.jl create mode 100644 src/parseblock.jl create mode 100644 src/replblock.jl create mode 100644 test/codeblock.jl create mode 100644 test/parseblock.jl create mode 100644 test/sandbox.jl diff --git a/src/CodeEvaluation.jl b/src/CodeEvaluation.jl index 05d21fb..9329c23 100644 --- a/src/CodeEvaluation.jl +++ b/src/CodeEvaluation.jl @@ -2,6 +2,9 @@ module CodeEvaluation using IOCapture: IOCapture using REPL: REPL +include("parseblock.jl") include("sandbox.jl") +include("codeblock.jl") +include("replblock.jl") end diff --git a/src/codeblock.jl b/src/codeblock.jl new file mode 100644 index 0000000..e245a7f --- /dev/null +++ b/src/codeblock.jl @@ -0,0 +1,10 @@ +""" + CodeEvaluation.codeblock!(sandbox::Sandbox, code::AbstractString) -> Result + +... +""" +function codeblock!(sandbox::Sandbox, code::AbstractString; color::Bool=true) + exprs = CodeEvaluation.parseblock(code) + block_expr = Expr(:block, (expr.expr for expr in exprs)...) + return evaluate!(sandbox, block_expr; setans=true, color) +end diff --git a/src/parseblock.jl b/src/parseblock.jl new file mode 100644 index 0000000..f6762f4 --- /dev/null +++ b/src/parseblock.jl @@ -0,0 +1,87 @@ +struct ParsedExpression + expr::Any + code::SubString{String} +end + +""" +Returns a vector of parsed expressions and their corresponding raw strings. + +Returns a `Vector` of tuples `(expr, code)`, where `expr` is the corresponding expression +(e.g. a `Expr` or `Symbol` object) and `code` is the string of code the expression was +parsed from. + +The keyword argument `skip = N` drops the leading `N` lines from the input string. + +If `raise=false` is passed, the `Meta.parse` does not raise an exception on parse errors, +but instead returns an expression that will raise an error when evaluated. `parseblock` +returns this expression normally and it must be handled appropriately by the caller. + +The `linenumbernode` can be passed as a `LineNumberNode` to give information about filename +and starting line number of the block. +""" +function parseblock( + code::AbstractString; + skip=0, + keywords=true, + raise=true, + linenumbernode=nothing +) + # Drop `skip` leading lines from the code block. Needed for deprecated `{docs}` syntax. + code = string(code, '\n') + code = last(split(code, '\n', limit=skip + 1)) + endofstr = lastindex(code) + results = ParsedExpression[] + cursor = 1 + while cursor < endofstr + # Check for keywords first since they will throw parse errors if we `parse` them. + line = match(r"^(.*)\r?\n"m, SubString(code, cursor)).match + keyword = Symbol(strip(line)) + (ex, ncursor) = if keywords && haskey(Docs.keywords, keyword) + (QuoteNode(keyword), cursor + lastindex(line)) + else + try + Meta.parse(code, cursor; raise=raise) + catch err + @error "parse error" + break + end + end + str = SubString(code, cursor, prevind(code, ncursor)) + if !isempty(strip(str)) && ex !== nothing + push!(results, ParsedExpression(ex, str)) + end + cursor = ncursor + end + if linenumbernode isa LineNumberNode + exs = Meta.parseall(code; filename=linenumbernode.file).args + @assert length(exs) == 2 * length(results) "Issue at $linenumbernode:\n$code" + for (i, ex) in enumerate(Iterators.partition(exs, 2)) + @assert ex[1] isa LineNumberNode + expr = Expr(:toplevel, ex...) # LineNumberNode + expression + # in the REPL each evaluation is considered a new file, e.g. + # REPL[1], REPL[2], ..., so try to mimic that by incrementing + # the counter for each sub-expression in this code block + if linenumbernode.file === Symbol("REPL") + newfile = "REPL[$i]" + # to reset the line counter for each new "file" + lineshift = 1 - ex[1].line + _update_linenumbernodes!(expr, newfile, lineshift) + else + _update_linenumbernodes!(expr, linenumbernode.file, linenumbernode.line) + end + results[i] = ParsedExpression(expr, results[i][2]) + end + end + results +end + +function _update_linenumbernodes!(x::Expr, newfile, lineshift) + for i = 1:length(x.args) + x.args[i] = _update_linenumbernodes!(x.args[i], newfile, lineshift) + end + return x +end +_update_linenumbernodes!(x::Any, newfile, lineshift) = x +function _update_linenumbernodes!(x::LineNumberNode, newfile, lineshift) + return LineNumberNode(x.line + lineshift, newfile) +end diff --git a/src/replblock.jl b/src/replblock.jl new file mode 100644 index 0000000..54663d7 --- /dev/null +++ b/src/replblock.jl @@ -0,0 +1,15 @@ +""" + struct REPLResult +""" +struct REPLResult1 end + +""" + CodeEvaluation.repl!(sandbox::Sandbox, code::AbstractString) -> AbstractValue + +Evaluates the code in a special REPL-mode, where `code` gets split up into expressions, +each of which gets evaluated one by one. The output is a string representing what this +would look like if each expression had been evaluated in the REPL as separate commands. +""" +function repl!(sandbox::Sandbox, code::AbstractString) + +end diff --git a/src/sandbox.jl b/src/sandbox.jl index e8300ef..5c06dd7 100644 --- a/src/sandbox.jl +++ b/src/sandbox.jl @@ -23,12 +23,21 @@ and the code is evaluated within the context of that module. - `pwd :: String`: The working directory where the code gets evaluated (irrespective of the current working directory of the process). +# Constructors + +```julia +Sandbox([name::Symbol]; workingdirectory::AbstractString=pwd()) +``` + +Creates a new `Sandbox` object. If `name` is not provided, a unique name is generated. +`workingdirectory` can be used to set a working directory that is different from the current +one. + See also: [`evaluate!`](@ref). """ mutable struct Sandbox m::Module pwd::String - _codebuffer::IOBuffer function Sandbox( name::Union{Symbol,Nothing}=nothing; @@ -37,21 +46,29 @@ mutable struct Sandbox if isnothing(name) name = Symbol("__CodeEvaluation__", _gensym_string()) end - return new(_sandbox_module(name), workingdirectory, IOBuffer(),) + return new(_sandbox_module(name), workingdirectory) end end - # TODO: by stripping the #-s, we're probably losing the uniqueness guarantee? _gensym_string() = lstrip(string(gensym()), '#') """ - Base.write(sandbox::Sandbox, code::AbstractString) -> Int + Core.eval(sandbox::Sandbox, expr) -> Any + +Convenience function that evaluates the given Julia expression in the sandbox module. +This is low-level and does not do any handling of evalution (like enforcing the working +directory, capturing outputs, or error handling). +""" +function Base.Core.eval(sandbox::Sandbox, expr) + return Core.eval(sandbox.m, expr) +end + +""" + Base.nameof(sandbox::Sandbox) -> Symbol -Writes the code `code` to the `Sandbox` object, which can then be evaluated by -[`evaluate!`](@ref). This can be called multiple times to append code to a buffer, -and then the whole buffer can be evaluated at once. +Returns the name of the underlying module of the `Sandbox` object. """ -Base.write(sandbox::Sandbox, code) = write(sandbox._codebuffer, code) +Base.nameof(sandbox::Sandbox) = nameof(sandbox.m) """ abstract type AbstractValue end @@ -85,42 +102,41 @@ Contains the result of an evaluation (see [`evaluate!`](@ref)). of the evaluation (success vs error etc), this will be of a different subtype of [`AbstractValue`](@ref). - `output :: String`: The captured stdout and stderr output of the evaluation. -- `show :: String`: The `show` representation of the resulting object. """ struct Result sandbox::Sandbox _value::AbstractValue output::String - show::String - _source::Union{String, Nothing} - _expressions::Vector{Tuple{Any, String}} + _source_expr::Any end function Base.getproperty(r::Result, name::Symbol) if name === :error return getfield(r, :_value) isa ExceptionValue elseif name === :value - # TODO: change to _value[] ? - return getfield(r, :_value) + return getfield(r, :_value)[] else return getfield(r, name) end end function Base.propertynames(::Type{Result}) - return (:sandbox, :value, :output, :show, :error) + return (:sandbox, :value, :output, :error) end """ - evaluate!(sandbox::Sandbox, [code::AbstractString]; kwargs...) + CodeEvaluation.evaluate!(sandbox::Sandbox, expr; kwargs...) -> Result -Evaluates the code in the buffer of the `Sandbox` object. +Low-level function to evaluate Julia expressions in a sandbox. The keyword arguments can be +used to control how exactly the code is evaluated. # Keyword arguments -- `ansicolor :: Bool=true`: whether or not to capture colored output (i.e. controls the IOContext +- `setans :: Bool=false`: whether or not to set the result of the expression to `ans`, emulating + the behavior of the Julia REPL. +- `softscope :: Bool=false`: evaluates the code in REPL softscope mode. +- `color :: Bool=true`: whether or not to capture colored output (i.e. controls the IOContext of the output stream; see the `IOCapture.capture` function for more details). -- `repl :: Bool=false`: evaluates the code in "REPL mode". # REPL mode @@ -130,146 +146,33 @@ When evaluating the code in "REPL mode" (`repl = true`), there are the following - It honors the semicolon suppression (i.e. the result of the last expression is set to `nothing` if the line ends with a semicolon). """ -function evaluate! end - -function evaluate!(sandbox::Sandbox, code::AbstractString; kwargs...) - write(sandbox, code) - return evaluate!(sandbox; kwargs...) -end - -function evaluate!(sandbox::Sandbox; color::Bool=true, repl::Bool=false) - code = String(take!(sandbox._codebuffer)) - - # Evaluate the code block. We redirect stdout/stderr to `buffer`. - result, buffer = nothing, IOBuffer() - - # TODO: use keywords, linenumbernode? - expressions = _parseblock(code) - for (ex, str) in expressions - if repl - ex = REPL.softscope(ex) - end - c = IOCapture.capture(; rethrow=InterruptException, color) do - cd(sandbox.pwd) do - Core.eval(sandbox.m, ex) - end +function evaluate!( + sandbox::Sandbox, + expr; + color::Bool=true, + softscope::Bool=false, + setans::Bool=false +) + if softscope + expr = REPL.softscope(expr) + end + c = IOCapture.capture(; rethrow=InterruptException, color) do + cd(sandbox.pwd) do + Core.eval(sandbox, expr) end - print(buffer, c.output) - if c.error - return Result( - sandbox, - ExceptionValue(c.value, c.backtrace, c.backtrace #= TODO =#), - String(take!(buffer)), - sprint(showerror, c.value), # TODO - code, - expressions, - ) - else + end + value = if c.error + ExceptionValue(c.value, c.backtrace, c.backtrace) + else + if setans Core.eval(sandbox.m, Expr(:global, Expr(:(=), :ans, QuoteNode(c.value)))) - result = c.value end + AnsValue(c.value) end - return Result( sandbox, - AnsValue(result), - String(take!(buffer)), - sprint(show, result), # TODO - code, - expressions, + value, + c.output, + expr, ) end - -""" - Base.nameof(sandbox::Sandbox) -> Symbol - -Returns the name of the underlying module of the `Sandbox` object. -""" -Base.nameof(sandbox::Sandbox) = nameof(sandbox.m) - -""" -Returns a vector of parsed expressions and their corresponding raw strings. - -Returns a `Vector` of tuples `(expr, code)`, where `expr` is the corresponding expression -(e.g. a `Expr` or `Symbol` object) and `code` is the string of code the expression was -parsed from. - -The keyword argument `skip = N` drops the leading `N` lines from the input string. - -If `raise=false` is passed, the `Meta.parse` does not raise an exception on parse errors, -but instead returns an expression that will raise an error when evaluated. `_parseblock` -returns this expression normally and it must be handled appropriately by the caller. - -The `linenumbernode` can be passed as a `LineNumberNode` to give information about filename -and starting line number of the block (requires Julia 1.6 or higher). -""" -function _parseblock( - code::AbstractString; - skip=0, - keywords=true, - raise=true, - linenumbernode=nothing -) - # Drop `skip` leading lines from the code block. Needed for deprecated `{docs}` syntax. - code = string(code, '\n') - code = last(split(code, '\n', limit=skip + 1)) - endofstr = lastindex(code) - results = [] - cursor = 1 - while cursor < endofstr - # Check for keywords first since they will throw parse errors if we `parse` them. - line = match(r"^(.*)\r?\n"m, SubString(code, cursor)).match - keyword = Symbol(strip(line)) - (ex, ncursor) = if keywords && haskey(Docs.keywords, keyword) - (QuoteNode(keyword), cursor + lastindex(line)) - else - try - Meta.parse(code, cursor; raise=raise) - catch err - @error "parse error" - break - end - end - str = SubString(code, cursor, prevind(code, ncursor)) - if !isempty(strip(str)) && ex !== nothing - push!(results, (ex, str)) - end - cursor = ncursor - end - if linenumbernode isa LineNumberNode - exs = Meta.parseall(code; filename=linenumbernode.file).args - @assert length(exs) == 2 * length(results) "Issue at $linenumbernode:\n$code" - for (i, ex) in enumerate(Iterators.partition(exs, 2)) - @assert ex[1] isa LineNumberNode - expr = Expr(:toplevel, ex...) # LineNumberNode + expression - # in the REPL each evaluation is considered a new file, e.g. - # REPL[1], REPL[2], ..., so try to mimic that by incrementing - # the counter for each sub-expression in this code block - if linenumbernode.file === Symbol("REPL") - newfile = "REPL[$i]" - # to reset the line counter for each new "file" - lineshift = 1 - ex[1].line - _update_linenumbernodes!(expr, newfile, lineshift) - else - _update_linenumbernodes!(expr, linenumbernode.file, linenumbernode.line) - end - results[i] = (expr, results[i][2]) - end - end - results -end - -function _update_linenumbernodes!(x::Expr, newfile, lineshift) - for i = 1:length(x.args) - x.args[i] = _update_linenumbernodes!(x.args[i], newfile, lineshift) - end - return x -end -_update_linenumbernodes!(x::Any, newfile, lineshift) = x -function _update_linenumbernodes!(x::LineNumberNode, newfile, lineshift) - return LineNumberNode(x.line + lineshift, newfile) -end - -function Base.Core.eval(sandbox::Sandbox, expr) - return Core.eval(sandbox.m, expr) -end diff --git a/test/codeblock.jl b/test/codeblock.jl new file mode 100644 index 0000000..3404981 --- /dev/null +++ b/test/codeblock.jl @@ -0,0 +1,113 @@ +@testset "codeblock! - basic" begin + # Basic cases + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "2+2") + @test !r.error + @test r.value == 4 + @test r.output == "" + end + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, ":foo") + @test !r.error + @test r.value === :foo + @test r.output == "" + end + # Output capture + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "print(\"123\")") + @test !r.error + @test r.value === nothing + @test r.output == "123" + end + # Multi-line evaluation + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "x=25\nx *= 2\nx - 8") + @test !r.error + @test r.value == 42 + @test r.output == "" + end + # Complex session + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "x=25\nx *= 2\ny = x - 8") + @test !r.error + @test r.value == 42 + @test r.output == "" + + r = CodeEvaluation.codeblock!(sb, "print(string(y))") + @test !r.error + @test r.value === nothing + @test r.output == "42" + + r = CodeEvaluation.codeblock!(sb, "s = string(y); println(s); length(s)") + @test !r.error + @test r.value == 2 + @test r.output == "42\n" + end +end + +@testset "codeblock! - errors" begin + # Error handling + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "error(\"x\")") + @test r.error + @test r.value isa ErrorException + @test r.value.msg == "x" + @test r.output == "" + end + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "print(\"x\"); error(\"x\"); print(\"y\")") + @test r.error + @test r.value isa ErrorException + @test r.value.msg == "x" + @test r.output == "x" + end + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.codeblock!(sb, "print(\"x\")\nerror(\"x\")\nprint(\"y\")") + @test r.error + @test r.value isa ErrorException + @test r.value.msg == "x" + @test r.output == "x" + end +end + +@testset "codeblock! - working directory" begin + # Working directory + mktempdir() do path + let sb = CodeEvaluation.Sandbox(; workingdirectory=path) + let r = CodeEvaluation.codeblock!(sb, "pwd()") + @test !r.error + @test r.value == path + @test r.output == "" + end + + write(joinpath(path, "test.txt"), "123") + let r = CodeEvaluation.codeblock!(sb, """ + isfile("test.txt"), read("test.txt", String) + """) + @test !r.error + @test r.value === (true, "123") + end + let r = CodeEvaluation.codeblock!(sb, """ + isfile("does-not-exist.txt") + """) + @test !r.error + @test r.value === false + end + let r = CodeEvaluation.codeblock!(sb, """ + read("does-not-exist.txt", String) + """) + @test r.error + @test r.value isa SystemError + end + end + end +end + +@testset "codeblock! - parse errors" begin + sb = CodeEvaluation.Sandbox() + let r = CodeEvaluation.codeblock!(sb, "...") + @test_broken r.error + @test_broken r.value isa ParseError + @test r.output == "" + end +end diff --git a/test/parseblock.jl b/test/parseblock.jl new file mode 100644 index 0000000..66380b9 --- /dev/null +++ b/test/parseblock.jl @@ -0,0 +1,67 @@ +@testset "basic" begin + exprs = CodeEvaluation.parseblock( + """ + x += 3 + γγγ_γγγ + γγγ + """ + ) + @test isa(exprs, Vector{CodeEvaluation.ParsedExpression}) + @test length(exprs) === 3 + + let expr = exprs[1] + @test expr.expr isa Expr + @test expr.expr.head === :(+=) + @test expr.code == "x += 3\n" + end + + let expr = exprs[2] + @test expr.expr === :γγγ_γγγ + @test expr.code == "γγγ_γγγ\n" + end + + let expr = exprs[3] + @test expr.expr === :γγγ + if VERSION >= v"1.10.0-DEV.1520" # JuliaSyntax merge + @test expr.code == "γγγ\n\n" + else + @test expr.code == "γγγ\n" + end + end +end + +# These tests were covering cases reported in +# https://github.com/JuliaDocs/Documenter.jl/issues/749 +# https://github.com/JuliaDocs/Documenter.jl/issues/790 +# https://github.com/JuliaDocs/Documenter.jl/issues/823 +@testset "line endings" begin + parse(s) = CodeEvaluation.parseblock(s) + for LE in ("\r\n", "\n") + l1, l2 = parse("x = Int[]$(LE)$(LE)push!(x, 1)$(LE)") + @test l1.expr == :(x = Int[]) + @test l2.expr == :(push!(x, 1)) + if VERSION >= v"1.10.0-DEV.1520" # JuliaSyntax merge + @test l1.code == "x = Int[]$(LE)$(LE)" + @test l2.code == "push!(x, 1)$(LE)\n" + else + @test l1.code == "x = Int[]$(LE)" + @test l2.code == "push!(x, 1)$(LE)" + end + end +end + +@testset "multi-expr" begin + let exprs = CodeEvaluation.parseblock("x; y; z") + @test length(exprs) == 1 + @test exprs[1].expr == Expr(:toplevel, :x, :y, :z) + @test exprs[1].code == "x; y; z\n" + end + + let exprs = CodeEvaluation.parseblock("x; y; z\nq\n\n") + @test length(exprs) == 2 + @test exprs[1].expr == Expr(:toplevel, :x, :y, :z) + @test exprs[1].code == "x; y; z\n" + @test exprs[2].expr == :q + @test exprs[2].code == "q\n\n\n" + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 45bad09..d8de2e6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,113 +2,13 @@ using CodeEvaluation using Test @testset "CodeEvaluation.jl" begin - @testset "_parseblock" begin - code = """ - x += 3 - γγγ_γγγ - γγγ - """ - exprs = CodeEvaluation._parseblock(code) - - @test isa(exprs, Vector) - @test length(exprs) === 3 - - @test isa(exprs[1][1], Expr) - @test exprs[1][1].head === :+= - @test exprs[1][2] == "x += 3\n" - - @test exprs[2][2] == "γγγ_γγγ\n" - - @test exprs[3][1] === :γγγ - if VERSION >= v"1.10.0-DEV.1520" # JuliaSyntax merge - @test exprs[3][2] == "γγγ\n\n" - else - @test exprs[3][2] == "γγγ\n" - end + @testset "parseblock()" begin + include("parseblock.jl") end - - # These tests were covering cases reported in - # https://github.com/JuliaDocs/Documenter.jl/issues/749 - # https://github.com/JuliaDocs/Documenter.jl/issues/790 - # https://github.com/JuliaDocs/Documenter.jl/issues/823 - let parse(x) = CodeEvaluation._parseblock(x) - for LE in ("\r\n", "\n") - l1, l2 = parse("x = Int[]$(LE)$(LE)push!(x, 1)$(LE)") - @test l1[1] == :(x = Int[]) - @test l2[1] == :(push!(x, 1)) - if VERSION >= v"1.10.0-DEV.1520" # JuliaSyntax merge - @test l1[2] == "x = Int[]$(LE)$(LE)" - @test l2[2] == "push!(x, 1)$(LE)\n" - else - @test l1[2] == "x = Int[]$(LE)" - @test l2[2] == "push!(x, 1)$(LE)" - end - end + @testset "Sandbox" begin + include("sandbox.jl") end - - @testset "evaluate!" begin - let sb = CodeEvaluation.Sandbox(:foo; workingdirectory=@__DIR__) - write(sb, "2 + 2") - r = CodeEvaluation.evaluate!(sb) - @test r isa CodeEvaluation.Result - @test r.sandbox === sb - @test r.value isa CodeEvaluation.AnsValue - @test r.value[] === 4 - @test r.output === "" - end - - let sb = CodeEvaluation.Sandbox(:foo; workingdirectory=@__DIR__) - write(sb, "print(\"123\")") - r = CodeEvaluation.evaluate!(sb) - @test r isa CodeEvaluation.Result - @test r.sandbox === sb - @test r.value isa CodeEvaluation.AnsValue - @test r.value[] === nothing - @test r.output === "123" - end - - let sb = CodeEvaluation.Sandbox(:foo; workingdirectory=@__DIR__) - write( - sb, - """ - x = 2 + 2 - print(x) - """ - ) - write(sb, "x + 1") - r = CodeEvaluation.evaluate!(sb) - @test r isa CodeEvaluation.Result - @test r.sandbox === sb - @test r.value isa CodeEvaluation.AnsValue - @test r.value[] === 5 - @test r.output === "4" - end - - let sb = CodeEvaluation.Sandbox(:foo; workingdirectory=@__DIR__) - r = CodeEvaluation.evaluate!(sb, """error("x")""") - @test r isa CodeEvaluation.Result - @test r.sandbox === sb - @test r.value isa CodeEvaluation.ExceptionValue - @test r.value[] isa ErrorException - @test r.value[].msg == "x" - @test r.output === "" - end - - let sb = CodeEvaluation.Sandbox(:foo; workingdirectory=@__DIR__) - r = CodeEvaluation.evaluate!( - sb, - """ - print("x") - error("x") - print("y") - """ - ) - @test r isa CodeEvaluation.Result - @test r.sandbox === sb - @test r.value isa CodeEvaluation.ExceptionValue - @test r.value[] isa ErrorException - @test r.value[].msg == "x" - @test r.output === "x" - end + @testset "codeblock!" begin + include("codeblock.jl") end end diff --git a/test/sandbox.jl b/test/sandbox.jl new file mode 100644 index 0000000..aebebb3 --- /dev/null +++ b/test/sandbox.jl @@ -0,0 +1,169 @@ +@testset "Sandbox" begin + sb = CodeEvaluation.Sandbox(:foo) + @test isa(sb, CodeEvaluation.Sandbox) + @test nameof(sb) == :foo + @test sb.pwd == pwd() + + sb = CodeEvaluation.Sandbox(; workingdirectory=@__DIR__) + @test isa(sb, CodeEvaluation.Sandbox) + @test nameof(sb) isa Symbol + @test sb.pwd == @__DIR__ +end + +@testset "Core.eval" begin + sb = CodeEvaluation.Sandbox() + @test Core.eval(sb, :(x = 2 + 2)) == 4 + @test Core.eval(sb, :x) == 4 + @test_throws UndefVarError Core.eval(sb, :y) +end + +@testset "evaluate! - basic" begin + sb = CodeEvaluation.Sandbox() + + r = CodeEvaluation.evaluate!(sb, :(2 + 2)) + @test !r.error + @test r.value === 4 + @test r.output === "" + + r = CodeEvaluation.evaluate!(sb, :x) + @test r.error + @test r.value isa UndefVarError + @test r.output === "" + + r = CodeEvaluation.evaluate!(sb, :(x = 2; nothing)) + @test !r.error + @test r.value === nothing + @test r.output === "" + + r = CodeEvaluation.evaluate!(sb, :x) + @test !r.error + @test r.value === 2 + @test r.output === "" +end + +@testset "evaluate! - ans" begin + # Setting the 'ans' variable is opt-in, so by default + # it does not get set. + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.evaluate!(sb, :(2 + 2)) + @test !r.error + @test r.value === 4 + @test r.output === "" + r = CodeEvaluation.evaluate!(sb, :ans) + @test r.error + @test r.value isa UndefVarError + @test r.output === "" + end + # If we set the 'setans' flag to true, then it does. + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.evaluate!(sb, :(2 + 2); setans=true) + @test !r.error + @test r.value === 4 + @test r.output === "" + r = CodeEvaluation.evaluate!(sb, :ans) + @test !r.error + @test r.value === 4 + @test r.output === "" + # Not setting it again, so it stays '4' + r = CodeEvaluation.evaluate!(sb, :(3 * 3); setans=false) + @test !r.error + @test r.value === 9 + @test r.output === "" + r = CodeEvaluation.evaluate!(sb, :ans) + @test !r.error + @test r.value === 4 + @test r.output === "" + end +end + +@testset "evaluate! - pwd" begin + mktempdir() do path + # By default, the sandbox picks up the current working directory when the sandbox + # gets constructed. + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.evaluate!(sb, :(pwd())) + @test !r.error + @test r.value != path + @test r.value == pwd() + @test r.output === "" + end + # But we can override that + let sb = CodeEvaluation.Sandbox(; workingdirectory=path) + r = CodeEvaluation.evaluate!(sb, :(pwd())) + @test !r.error + @test r.value == path + @test r.output === "" + end + end +end + +@testset "evaluate! - output capture" begin + sb = CodeEvaluation.Sandbox() + + r = CodeEvaluation.evaluate!(sb, :(print("123"))) + @test !r.error + @test r.value === nothing + @test r.output === "123" + + # stdout and stderr gets concatenated + r = CodeEvaluation.evaluate!(sb, quote + println(stdout, "123") + println(stderr, "456") + end) + @test !r.error + @test r.value === nothing + @test r.output === "123\n456\n" + + # We can also capture the output in color + r = CodeEvaluation.evaluate!(sb, quote + printstyled("123"; color=:red) + end) + @test !r.error + @test r.value === nothing + @test r.output === "\e[31m123\e[39m" + # But this can be disabled with color=false + r = CodeEvaluation.evaluate!(sb, quote + printstyled("123"; color=:red) + end; color=false) + @test !r.error + @test r.value === nothing + @test r.output === "123" + + # Capturing output logging macros + r = CodeEvaluation.evaluate!(sb, quote + @info "12345" + 42 + end; color=false) + @test !r.error + @test r.value === 42 + @test r.output == "[ Info: 12345\n" +end + +@testset "evaluate! - scoping" begin + expr = quote + s = 0 + for i = 1:10 + s = i + end + s + end + + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.evaluate!(sb, expr; color=false) + @test !r.error + @test r.value === 0 + # The evaluation prints a warning that should look something like this: + # + # ┌ Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable. + # └ @ ~/.../CodeEvaluation/test/sandbox.jl:146 + @test contains(r.output, "┌ Warning:") + end + # However, if we set softscope=true, it follows the REPL soft scoping rules + # https://docs.julialang.org/en/v1/manual/variables-and-scoping/#on-soft-scope + let sb = CodeEvaluation.Sandbox() + r = CodeEvaluation.evaluate!(sb, expr; softscope=true, color=false) + @test !r.error + @test r.value === 10 + @test r.output == "" + end +end