Skip to content

Commit 9de5866

Browse files
authored
Source ranges (#339)
* implement source range mechanism * don't look up additional line that doesn't exist * add test * formatting * explain test * add changelog entry * try fixing windows line splitting * wasn't a regex * use constant time lookup instead * add test for error condition * test no source ranges as well to cover the last line * hit one more edge case * handle quarto empty line bug * replace `.start` with `first()`
1 parent fa10ed9 commit 9de5866

File tree

7 files changed

+245
-39
lines changed

7 files changed

+245
-39
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Added the ability to read a `sourceRanges` attribute from quarto to correctly point to included files in stacktraces [#339].
1213
- Support new requirement from `quarto` to population environment variables
1314
explicitly in the notebook process [#306].
1415
- Contributing guidelines with AI assistance policy and AGENTS.md for AI coding assistants [#335].
@@ -468,3 +469,4 @@ caching is enabled. Delete this folder to clear the cache. [#259]
468469
[#305]: https://github.com/PumasAI/QuartoNotebookRunner.jl/issues/305
469470
[#306]: https://github.com/PumasAI/QuartoNotebookRunner.jl/issues/306
470471
[#335]: https://github.com/PumasAI/QuartoNotebookRunner.jl/issues/335
472+
[#339]: https://github.com/PumasAI/QuartoNotebookRunner.jl/issues/339

src/server.jl

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ mutable struct File
6666
end
6767
end
6868

69+
struct SourceRange
70+
file::Union{String,Nothing}
71+
lines::UnitRange{Int}
72+
source_line::Union{Nothing,Int} # first line in source
73+
end
74+
75+
function SourceRange(file, lines, source_lines::UnitRange)
76+
if length(lines) != length(source_lines)
77+
error(
78+
"Mismatching lengths of lines $lines ($(length(lines))) and source_lines $source_lines ($(length(source_lines)))",
79+
)
80+
end
81+
SourceRange(file, lines, first(source_lines))
82+
end
83+
6984
function _has_juliaup()
7085
try
7186
success(`juliaup --version`) && success(`julia --version`)
@@ -339,13 +354,15 @@ function evaluate!(
339354
options::Union{String,Dict{String,Any}} = Dict{String,Any}(),
340355
chunk_callback = (i, n, c) -> nothing,
341356
markdown::Union{String,Nothing} = nothing,
357+
source_ranges::Union{Nothing,Vector} = nothing,
342358
)
343359
_check_output_dst(output)
344360

345361
options = _parsed_options(options)
346362
path = abspath(f.path)
347363
if isfile(path)
348-
source_code_hash, raw_chunks, file_frontmatter = raw_text_chunks(f, markdown)
364+
source_code_hash, raw_chunks, file_frontmatter =
365+
raw_text_chunks(f, markdown; source_ranges)
349366
merged_options = _extract_relevant_options(file_frontmatter, options)
350367

351368
# A change of parameter values must invalidate the source code hash.
@@ -583,9 +600,9 @@ write_json(::Nothing, data) = data
583600
Return a vector of raw markdown and code chunks from `file` ready for evaluation
584601
by `evaluate_raw_cells!`.
585602
"""
586-
raw_text_chunks(file::File, ::Nothing) = raw_text_chunks(file.path)
587-
raw_text_chunks(file::File, markdown::String) =
588-
raw_markdown_chunks_from_string(file.path, markdown)
603+
raw_text_chunks(file::File, ::Nothing; source_ranges = nothing) = raw_text_chunks(file.path)
604+
raw_text_chunks(file::File, markdown::String; source_ranges = nothing) =
605+
raw_markdown_chunks_from_string(file.path, markdown; source_ranges)
589606

590607
function raw_text_chunks(path::String)
591608
endswith(path, ".qmd") && return raw_markdown_chunks(path)
@@ -607,7 +624,27 @@ raw_markdown_chunks(path::String) =
607624

608625
struct Unset end
609626

610-
function raw_markdown_chunks_from_string(path::String, markdown::String)
627+
function compute_line_file_lookup(nlines, path, source_ranges)
628+
nlines_ranges = maximum(r -> r.lines.stop, source_ranges) # number of lines reported might be different from the markdown string due to quarto bugs
629+
lookup = fill((; file = "unknown", line = 0), nlines_ranges)
630+
for source_range in source_ranges
631+
file::String = something(source_range.file, "unknown")
632+
for line in source_range.lines
633+
source_line = line - first(source_range.lines) + source_range.source_line
634+
lookup[line] = (; file, line = source_line)
635+
end
636+
end
637+
return lookup
638+
end
639+
function compute_line_file_lookup(nlines, path, source_ranges::Nothing)
640+
return [(; file = path, line) for line = 1:nlines]
641+
end
642+
643+
function raw_markdown_chunks_from_string(
644+
path::String,
645+
markdown::String;
646+
source_ranges = nothing,
647+
)
611648
raw_chunks = []
612649
source_code_hash = hash(VERSION)
613650
pars = Parser()
@@ -616,6 +653,9 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
616653
source_code_hash = hash(file_fromtmatter, source_code_hash)
617654
source_lines = collect(eachline(IOBuffer(markdown)))
618655
terminal_line = 1
656+
657+
line_file_lookup = compute_line_file_lookup(length(source_lines), path, source_ranges)
658+
619659
code_cells = false
620660
for (node, enter) in ast
621661
if enter &&
@@ -625,7 +665,7 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
625665
md = join(source_lines[terminal_line:(line-1)], "\n")
626666
push!(
627667
raw_chunks,
628-
(type = :markdown, source = md, file = path, line = terminal_line),
668+
(; type = :markdown, source = md, line_file_lookup[terminal_line]...),
629669
)
630670
if contains(md, r"`{(?:julia|python|r)} ")
631671
source_code_hash = hash(md, source_code_hash)
@@ -636,7 +676,7 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
636676
# this option could in the future also include a vector of line numbers, which knitr supports.
637677
# all other options seem to be quarto-rendering related, like where to put figure captions etc.
638678
source = node.literal
639-
cell_options = extract_cell_options(source; file = path, line = line)
679+
cell_options = extract_cell_options(source; line_file_lookup[line]...)
640680
evaluate = get(cell_options, "eval", Unset())
641681
if !(evaluate isa Union{Bool,Unset})
642682
error(
@@ -649,12 +689,11 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
649689
is_r_toplevel(node) ? :r : error("Unhandled code block language")
650690
push!(
651691
raw_chunks,
652-
(
692+
(;
653693
type = :code,
654694
language = language,
655695
source,
656-
file = path,
657-
line,
696+
line_file_lookup[line]...,
658697
evaluate,
659698
cell_options,
660699
),
@@ -666,7 +705,7 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
666705
md = join(source_lines[terminal_line:end], "\n")
667706
push!(
668707
raw_chunks,
669-
(type = :markdown, source = md, file = path, line = terminal_line),
708+
(; type = :markdown, source = md, line_file_lookup[terminal_line]...),
670709
)
671710
if contains(md, r"`{(?:julia|python|r)} ")
672711
source_code_hash = hash(md, source_code_hash)
@@ -690,7 +729,7 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
690729
if raw_chunks[end].type == :code
691730
push!(
692731
raw_chunks,
693-
(type = :markdown, source = "", file = path, line = terminal_line),
732+
(; type = :markdown, source = "", file = path, line = terminal_line),
694733
)
695734
end
696735

@@ -1499,6 +1538,7 @@ function run!(
14991538
showprogress::Bool = true,
15001539
options::Union{String,Dict{String,Any}} = Dict{String,Any}(),
15011540
chunk_callback = (i, n, c) -> nothing,
1541+
source_ranges::Union{Nothing,Vector} = nothing,
15021542
)
15031543
try
15041544
borrow_file!(server, path; options, optionally_create = true) do file
@@ -1519,7 +1559,15 @@ function run!(
15191559

15201560
result_task = Threads.@spawn begin
15211561
try
1522-
evaluate!(file, output; showprogress, options, markdown, chunk_callback)
1562+
evaluate!(
1563+
file,
1564+
output;
1565+
showprogress,
1566+
options,
1567+
markdown,
1568+
chunk_callback,
1569+
source_ranges,
1570+
)
15231571
finally
15241572
put!(file.run_decision_channel, :evaluate_finished)
15251573
end

src/socket.jl

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,6 @@ function _handle_response_internal(
345345
# Running:
346346

347347
if type == "run"
348-
options = _get_options(request.content)
349-
markdown = _get_markdown(options)
350-
351348
function chunk_callback(i, n, chunk)
352349
_write_json(
353350
socket,
@@ -362,6 +359,10 @@ function _handle_response_internal(
362359
end
363360

364361
result = try
362+
options = _get_options(request.content)
363+
markdown = _get_markdown(options)
364+
source_ranges = _get_source_ranges(request.content)
365+
365366
(;
366367
notebook = run!(
367368
notebooks,
@@ -370,6 +371,7 @@ function _handle_response_internal(
370371
markdown,
371372
showprogress,
372373
chunk_callback,
374+
source_ranges,
373375
)
374376
)
375377
catch error
@@ -464,6 +466,26 @@ _get_file(content::String) = content
464466
_get_options(content::Dict) = get(Dict{String,Any}, content, "options")
465467
_get_options(::String) = Dict{String,Any}()
466468

469+
function _get_source_ranges(content::Dict)
470+
ranges = get(content, "sourceRanges", nothing)
471+
ranges === nothing && return nothing
472+
return map(ranges) do range
473+
file = get(range, "file", nothing)
474+
_lines::Vector{Int} = range["lines"]
475+
@assert length(_lines) == 2
476+
lines = _lines[1]:_lines[2]
477+
_source_lines::Union{Nothing,Vector{Int}} = get(range, "sourceLines", nothing)
478+
source_lines = if _source_lines === nothing
479+
1:length(lines) # source lines are only missing in degenerate cases like additional newlines anyway so this doesn't really matter
480+
else
481+
@assert length(_source_lines) == 2
482+
_source_lines[1]:_source_lines[2]
483+
end
484+
SourceRange(file, lines, source_lines)
485+
end
486+
end
487+
_get_source_ranges(::String) = nothing
488+
467489
function _get_nested(d::Dict, keys...)
468490
_d = d
469491
for key in keys
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```{julia}
2+
print("$(@__FILE__):$(@__LINE__)")
3+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
engine: julia
3+
---
4+
5+
{{< include to_include.qmd >}}
6+
7+
```{julia}
8+
print("$(@__FILE__):$(@__LINE__)")
9+
```

test/testsets/socket_server/client.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,30 @@ function handle() {
2222
const isready = () => toJSON({ type: 'isready', content: '' });
2323
const status = () => toJSON({ type: 'status', content: '' });
2424

25-
const notebook = (arg) => {
26-
if (path.isAbsolute(arg)) {
27-
return arg
25+
const content = (arg) => {
26+
if (typeof arg === 'string') {
27+
if (path.isAbsolute(arg)) {
28+
return arg
29+
}
30+
throw new Error('No notebook with absolute path specified.');
2831
}
29-
throw new Error('No notebook with absolute path specified.');
32+
return arg;
3033
}
3134

3235
const type = process.argv[4];
33-
const arg = process.argv[5];
36+
const arg = process.argv.length >= 6 ? JSON.parse(process.argv[5]) : undefined;
3437

3538
switch (type) {
3639
case 'run':
37-
return run(notebook(arg));
40+
return run(content(arg));
3841
case 'close':
39-
return close(notebook(arg));
42+
return close(content(arg));
4043
case 'forceclose':
41-
return forceclose(notebook(arg));
44+
return forceclose(content(arg));
4245
case 'stop':
4346
return stop();
4447
case 'isopen':
45-
return isopen(notebook(arg));
48+
return isopen(content(arg));
4649
case 'isready':
4750
return isready();
4851
case 'status':

0 commit comments

Comments
 (0)