diff --git a/src/QuartoNotebookWorker/src/render.jl b/src/QuartoNotebookWorker/src/render.jl index 0f943d5..a3af60e 100644 --- a/src/QuartoNotebookWorker/src/render.jl +++ b/src/QuartoNotebookWorker/src/render.jl @@ -4,9 +4,16 @@ function render( line::Integer, cell_options::AbstractDict = Dict{String,Any}(), ) - return Base.@invokelatest( + # This records whether the outermost cell is an expandable cell, which we + # then return to the server so that it can decide whether to treat the cell + # results it gets back as an expansion or not. We can't decide this + # statically since expansion depends on whether the runtime type of the cell + # output is `is_expandable` or not. Recursive calls to `_render_thunk` don't + # matter to the server, it's just the outermost cell that matters. + is_expand_ref = Ref(false) + result = Base.@invokelatest( collect( - _render_thunk(code, cell_options) do + _render_thunk(code, cell_options, is_expand_ref) do Base.@invokelatest include_str( NotebookState.notebook_module(), code; @@ -16,8 +23,11 @@ function render( end, ) ) + return (result, is_expand_ref[]) end +is_expandable(@nospecialize(value)) = false + # Recursively render cell thunks. This might be an `include_str` call, # which is the starting point for a source cell, or it may be a # user-provided thunk that comes from a source cell with `expand` set @@ -26,9 +36,15 @@ function _render_thunk( thunk::Base.Callable, code::AbstractString, cell_options::AbstractDict = Dict{String,Any}(), + is_expand_ref::Ref{Bool} = Ref(false), ) captured, display_results = with_inline_display(thunk, cell_options) - if get(cell_options, "expand", false) === true + + is_expand = is_expandable(captured.value) || get(cell_options, "expand", false) === true + # Track in this side-channel whether the cell is to be expanded or not. + is_expand_ref[] = is_expand + + if is_expand if captured.error return ((; code = "", # an expanded cell that errored can't have returned code diff --git a/src/server.jl b/src/server.jl index 526a1f2..e754083 100644 --- a/src/server.jl +++ b/src/server.jl @@ -612,12 +612,9 @@ function evaluate_raw_cells!( @maybe_progress showprogress "$header" for (nth, chunk) in enumerate(chunks) if chunk.type === :code - expand_cell = get(chunk.cell_options, "expand", false) === true - # When we're not evaluating the code, or when there is an `expand` - # cell output then we immediately splice in the cell code. The - # results of evaluating an `expand` cell are added later on and are - # not considered direct outputs of this cell. - if !chunk.evaluate || expand_cell + if !chunk.evaluate + # Cells that are not evaluated are not executed, but they are + # still included in the notebook. push!( cells, (; @@ -626,12 +623,10 @@ function evaluate_raw_cells!( metadata = (;), source = process_cell_source(chunk.source), outputs = [], - execution_count = chunk.evaluate ? 1 : 0, + execution_count = 0, ), ) - end - - if chunk.evaluate + else chunk_callback(ith_chunk_to_evaluate, chunks_to_evaluate, chunk) ith_chunk_to_evaluate += 1 @@ -646,7 +641,26 @@ function evaluate_raw_cells!( $(chunk.cell_options), )) - for (mth, remote) in enumerate(Malt.remote_eval_fetch(f.worker, expr)) + worker_results, expand_cell = Malt.remote_eval_fetch(f.worker, expr) + + # When the result of the cell evaluation is a cell expansion + # then we insert the original cell contents before the expanded + # cells as a mock cell similar to if it has `eval: false` set. + if expand_cell + push!( + cells, + (; + id = string(nth), + cell_type = chunk.type, + metadata = (;), + source = process_cell_source(chunk.source), + outputs = [], + execution_count = 1, + ), + ) + end + + for (mth, remote) in enumerate(worker_results) outputs = [] processed = process_results(remote.results) @@ -800,7 +814,10 @@ function evaluate_raw_cells!( # There should only ever be a single result from an # inline evaluation since you can't pass cell # options and so `expand` will always be `false`. - remote = only(Malt.remote_eval_fetch(f.worker, expr)) + worker_results, expand_cell = + Malt.remote_eval_fetch(f.worker, expr) + expand_cell && error("inline code cells cannot be expanded") + remote = only(worker_results) if !isnothing(remote.error) # file location is not straightforward to determine with inline literals, but just printing the (presumably short) # code back instead of a location should be quite helpful diff --git a/test/examples/cell_expansion.qmd b/test/examples/cell_expansion.qmd index ab166cb..9ae7ea2 100644 --- a/test/examples/cell_expansion.qmd +++ b/test/examples/cell_expansion.qmd @@ -67,3 +67,20 @@ cells(q::QuartoCell) = (q,) QuartoCell(() -> 123, Dict(), "") ``` + +```{julia} +import QuartoNotebookWorker + +# Actual usage of this feature requires the `QuartoNotebookWorker` module to be +# weakdep of the package that would like to use it and define this method in an +# package extension module. You cannot add `QuartoNotebookWorker` as a real +# dependency since it is not, and cannot, be registered. +QuartoNotebookWorker.is_expandable(::QuartoCell) = true +``` + +Extending the `is_expandable` method to return `true` for the `QuartoCell` type +should result in the equivalent of `expand: true` being set for the cell. + +```{julia} +QuartoCell(() -> 123, Dict(), "") +``` diff --git a/test/testsets/cell_expansion.jl b/test/testsets/cell_expansion.jl index 8b0f70f..8e0c9e7 100644 --- a/test/testsets/cell_expansion.jl +++ b/test/testsets/cell_expansion.jl @@ -1,7 +1,7 @@ include("../utilities/prelude.jl") test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json - @test length(json["cells"]) == 13 + @test length(json["cells"]) == 18 cell = json["cells"][1] @test cell["cell_type"] == "markdown" @@ -80,6 +80,9 @@ test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json cell = json["cells"][13] @test cell["outputs"][1]["data"]["text/plain"] == "123" + + cell = json["cells"][18] + @test cell["outputs"][1]["data"]["text/plain"] == "123" end test_example(joinpath(@__DIR__, "../examples/cell_expansion_errors.qmd")) do json