Skip to content

Commit 442f3e4

Browse files
authored
Rewrite source range highlight() (#215)
`highlight()` now always prints whole lines of source code, and can highlight arbitrary ranges using box drawing characters, not just with ANSI colors. Use this to fix diagnostic printing so that it's comprehensible in a non color terminal and so that pasting errors into non-color environments works. The box drawing characters from WGL4 seem like a good balance of: * Relatively compatible because they're very old, dating from DOS era: https://en.wikipedia.org/wiki/Box-drawing_character#DOS - note we also use these for formatting log messages from Logging.ConsoleLogger. * Easy to distinguish from the user's source code I've also found it's helpful to prepend any lines of annotation with a Julia # comment where possible - this ensures that copy+paste into websites with syntax highlighting will highlight the annotations separately from the code. A simple example: julia> (x - (c <--- d)) ERROR: ParseError: (x - (c <--- d)) # └──┘ ── invalid operator @ REPL[48]:1:9 Also some semi-related changes included * Generalized/expanded _printstyled() function * Better diagnostic range for try-without-catch
1 parent fec6f3f commit 442f3e4

13 files changed

+400
-82
lines changed

src/diagnostics.jl

Lines changed: 24 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ end
4040
first_byte(d::Diagnostic) = d.first_byte
4141
last_byte(d::Diagnostic) = d.last_byte
4242
is_error(d::Diagnostic) = d.level == :error
43+
Base.range(d::Diagnostic) = first_byte(d):last_byte(d)
44+
45+
# Make relative path into a file URL
46+
function _file_url(filename)
47+
@static if Sys.iswindows()
48+
# TODO: Test this with windows terminal
49+
path = replace(abspath(filename), '\\'=>'/')
50+
else
51+
path = abspath(filename)
52+
end
53+
"file://$(path)"
54+
end
4355

4456
function show_diagnostic(io::IO, diagnostic::Diagnostic, source::SourceFile)
4557
color,prefix = diagnostic.level == :error ? (:light_red, "Error") :
@@ -49,76 +61,34 @@ function show_diagnostic(io::IO, diagnostic::Diagnostic, source::SourceFile)
4961
line, col = source_location(source, first_byte(diagnostic))
5062
linecol = "$line:$col"
5163
filename = source.filename
64+
file_href = nothing
5265
if !isnothing(filename)
5366
locstr = "$filename:$linecol"
54-
if get(io, :color, false)
55-
# Also add hyperlinks in color terminals
56-
url = "file://$(abspath(filename))#$linecol"
57-
locstr = "\e]8;;$url\e\\$locstr\e]8;;\e\\"
67+
if !startswith(filename, "REPL[")
68+
file_href = _file_url(filename)*"#$linecol"
5869
end
5970
else
6071
locstr = "line $linecol"
6172
end
62-
print(io, prefix, ": ")
63-
printstyled(io, diagnostic.message, color=color)
64-
printstyled(io, "\n", "@ $locstr", color=:light_black)
73+
_printstyled(io, "# $prefix @ ", fgcolor=:light_black)
74+
_printstyled(io, "$locstr", fgcolor=:light_black, href=file_href)
6575
print(io, "\n")
66-
67-
p = first_byte(diagnostic)
68-
q = last_byte(diagnostic)
69-
text = sourcetext(source)
70-
if q < p || (p == q && source[p] == '\n')
71-
# An empty or invisible range! We expand it symmetrically to make it
72-
# visible.
73-
p = max(firstindex(text), prevind(text, p))
74-
q = min(lastindex(text), nextind(text, q))
75-
end
76-
77-
# p and q mark the start and end of the diagnostic range. For context,
78-
# buffer these out to the surrouding lines.
79-
a,b = source_line_range(source, p, context_lines_before=2, context_lines_after=1)
80-
c,d = source_line_range(source, q, context_lines_before=1, context_lines_after=2)
81-
82-
hicol = (100,40,40)
83-
84-
# TODO: show line numbers on left
85-
86-
print(io, source[a:prevind(text, p)])
87-
# There's two situations, either
88-
if b >= c
89-
# The diagnostic range is compact and we show the whole thing
90-
# a...............
91-
# .....p...q......
92-
# ...............b
93-
_printstyled(io, source[p:q]; bgcolor=hicol)
94-
else
95-
# Or large and we trucate the code to show only the region around the
96-
# start and end of the error.
97-
# a...............
98-
# .....p..........
99-
# ...............b
100-
# (snip)
101-
# c...............
102-
# .....q..........
103-
# ...............d
104-
_printstyled(io, source[p:b]; bgcolor=hicol)
105-
println(io, "")
106-
_printstyled(io, source[c:q]; bgcolor=hicol)
107-
end
108-
print(io, source[nextind(text,q):d])
109-
println(io)
76+
highlight(io, source, range(diagnostic),
77+
note=diagnostic.message, notecolor=color,
78+
context_lines_before=1, context_lines_after=0)
11079
end
11180

11281
function show_diagnostics(io::IO, diagnostics::AbstractVector{Diagnostic}, source::SourceFile)
82+
first = true
11383
for d in diagnostics
84+
first || println(io)
85+
first = false
11486
show_diagnostic(io, d, source)
11587
end
11688
end
11789

11890
function show_diagnostics(io::IO, diagnostics::AbstractVector{Diagnostic}, text::AbstractString)
119-
if !isempty(diagnostics)
120-
show_diagnostics(io, diagnostics, SourceFile(text))
121-
end
91+
show_diagnostics(io, diagnostics, SourceFile(text))
12292
end
12393

12494
function emit_diagnostic(diagnostics::AbstractVector{Diagnostic},

src/parse_stream.jl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ function Base.show(io::IO, mime::MIME"text/plain", stream::ParseStream)
295295
println(io, "ParseStream at position $(_next_byte(stream))")
296296
end
297297

298-
function show_diagnostics(io::IO, stream::ParseStream, code)
299-
show_diagnostics(io, stream.diagnostics, code)
298+
function show_diagnostics(io::IO, stream::ParseStream)
299+
show_diagnostics(io, stream.diagnostics, sourcetext(stream))
300300
end
301301

302302
# We manage a pool of stream positions as parser working space
@@ -841,7 +841,7 @@ end
841841

842842
function emit_diagnostic(stream::ParseStream, mark::ParseStreamPosition; kws...)
843843
emit_diagnostic(stream, token_first_byte(stream, mark.token_index),
844-
_next_byte(stream) - 1; kws...)
844+
_next_byte(stream) - 1; kws...)
845845
end
846846

847847
function emit_diagnostic(stream::ParseStream, mark::ParseStreamPosition,
@@ -923,6 +923,7 @@ function validate_tokens(stream::ParseStream)
923923
t.orig_kind, t.next_byte)
924924
end
925925
end
926+
sort!(stream.diagnostics, by=first_byte)
926927
end
927928

928929
# Tree construction from the list of text ranges held by ParseStream

src/parser.jl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,6 +2230,7 @@ function parse_try(ps)
22302230
out_kind = K"try"
22312231
mark = position(ps)
22322232
bump(ps, TRIVIA_FLAG)
2233+
diagnostic_mark = position(ps)
22332234
parse_block(ps)
22342235
has_catch = false
22352236
has_else = false
@@ -2282,12 +2283,16 @@ function parse_try(ps)
22822283
emit_diagnostic(ps, m, position(ps),
22832284
warning="`catch` after `finally` will execute out of order")
22842285
end
2285-
if !has_catch && !has_finally
2286+
missing_recovery = !has_catch && !has_finally
2287+
if missing_recovery
22862288
# try x end ==> (try (block x) false false false false (error-t))
2287-
bump_invisible(ps, K"error", TRIVIA_FLAG, error="try without catch or finally")
2289+
bump_invisible(ps, K"error", TRIVIA_FLAG)
22882290
end
22892291
bump_closing_token(ps, K"end")
22902292
emit(ps, mark, out_kind, flags)
2293+
if missing_recovery
2294+
emit_diagnostic(ps, diagnostic_mark, error="try without catch or finally")
2295+
end
22912296
end
22922297

22932298
function parse_catch(ps::ParseState)

src/source_files.jl

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function Base.show(io::IO, ::MIME"text/plain", source::SourceFile)
8888
end
8989
end
9090

91-
function Base.getindex(source::SourceFile, rng::AbstractRange)
91+
function Base.getindex(source::SourceFile, rng::AbstractUnitRange)
9292
i = first(rng)
9393
# Convert byte range into unicode String character range.
9494
# Assumes valid unicode! (SubString doesn't give us a reliable way to opt
@@ -99,7 +99,7 @@ function Base.getindex(source::SourceFile, rng::AbstractRange)
9999
end
100100

101101
# TODO: Change view() here to `sourcetext` ?
102-
function Base.view(source::SourceFile, rng::AbstractRange)
102+
function Base.view(source::SourceFile, rng::AbstractUnitRange)
103103
i = first(rng)
104104
j = prevind(source.code, last(rng)+1)
105105
SubString(source.code, i, j)
@@ -120,3 +120,125 @@ Get the full source text of a `SourceFile` as a string.
120120
function sourcetext(source::SourceFile)
121121
return source.code
122122
end
123+
124+
125+
#-------------------------------------------------------------------------------
126+
# Tools for highlighting source ranges
127+
function _print_marker_line(io, prefix_str, str, underline, singleline, color,
128+
note, notecolor)
129+
# Whitespace equivalent in length to `prefix_str`
130+
# Getting exactly the same width of whitespace as `str` is tricky.
131+
# Especially for mixtures of tabs and spaces.
132+
# tabs are zero width according to textwidth
133+
indent = join(isspace(c) ? c : repeat(' ', textwidth(c)) for c in prefix_str)
134+
135+
# Assume tabs are 4 wide rather than 0. (fixme: implement tab alignment?)
136+
w = textwidth(str) + 4*count(c->c=='\t', str)
137+
if !isempty(indent)
138+
indent = "#" * (first(indent) == '\t' ? indent : indent[nextind(indent,1):end])
139+
end
140+
141+
midchar = ''
142+
startstr, endstr, singlestart = underline ? ("","","") : ("","","")
143+
144+
markline =
145+
if singleline
146+
w == 0 ? string(indent, startstr) :
147+
w == 1 ? string(indent, singlestart) :
148+
string(indent, startstr, repeat('', w-2), endstr)
149+
else
150+
if underline && isempty(indent) && w > 1
151+
string('#', repeat('', w-2), endstr)
152+
else
153+
s,e = underline ? ("", endstr) : (startstr, "")
154+
w == 0 ? string(indent, s, e) :
155+
string(indent, s, repeat('', w-1), e)
156+
end
157+
end
158+
if note isa AbstractString
159+
markline *= " ── "
160+
end
161+
_printstyled(io, markline; fgcolor=color)
162+
if !isnothing(note)
163+
if note isa AbstractString
164+
_printstyled(io, note, fgcolor=notecolor)
165+
else
166+
note(io, indent, w)
167+
end
168+
end
169+
end
170+
171+
"""
172+
Print the lines of source code surrounding the given byte `range`, which is
173+
highlighted with background `color` and markers in the text.
174+
"""
175+
function highlight(io::IO, source::SourceFile, range::UnitRange;
176+
color=(120,70,70), context_lines_before=2,
177+
context_lines_inner=1, context_lines_after=2,
178+
note=nothing, notecolor=nothing)
179+
p = first(range)
180+
q = last(range)
181+
182+
x,y = source_line_range(source, p;
183+
context_lines_before=context_lines_before,
184+
context_lines_after=context_lines_inner)
185+
a,b = source_line_range(source, p)
186+
c,d = source_line_range(source, q)
187+
z,w = source_line_range(source, q;
188+
context_lines_before=context_lines_inner,
189+
context_lines_after=context_lines_after)
190+
191+
p_line = source_line(source, p)
192+
q_line = source_line(source, q)
193+
194+
marker_line_color = :light_black
195+
196+
if p_line == q_line
197+
# x-----------------
198+
# a---p-------q----b
199+
# # └───────┘ ── note
200+
# -----------------w
201+
202+
hitext = source[p:q]
203+
print(io, source[x:p-1])
204+
_printstyled(io, hitext; bgcolor=color)
205+
print(io, source[q+1:d])
206+
source[d] == '\n' || print(io, "\n")
207+
_print_marker_line(io, source[a:p-1], hitext, true, true, marker_line_color, note, notecolor)
208+
else
209+
# x --------------
210+
# # ┌─────
211+
# a---p----b
212+
# --------------y
213+
# ---------------
214+
# z--------------
215+
# c --------q----d
216+
# #───────────┘ ── note
217+
# -----------------w
218+
219+
prefix1 = source[a:p-1]
220+
print(io, source[x:a-1])
221+
_print_marker_line(io, prefix1, source[p:b], false, false, marker_line_color, nothing, notecolor)
222+
print(io, '\n')
223+
print(io, prefix1)
224+
if q_line - p_line - 1 <= 2*context_lines_inner
225+
# The diagnostic range is compact and we show the whole thing
226+
_printstyled(io, source[p:q]; bgcolor=color)
227+
else
228+
# Or large and we trucate the code to show only the region around the
229+
# start and end of the error.
230+
_printstyled(io, source[p:y]; bgcolor=color)
231+
print(io, "\n")
232+
_printstyled(io, source[z:q]; bgcolor=color)
233+
end
234+
print(io, source[q+1:d])
235+
source[d] == '\n' || print(io, "\n")
236+
qline = source[c:q]
237+
_print_marker_line(io, "", qline, true, false, marker_line_color, note, notecolor)
238+
end
239+
if context_lines_after > 0 && d+1 < lastindex(source)
240+
print(io, '\n')
241+
w1 = source[w] == '\n' ? w - 1 : w
242+
print(io, source[d+1:w1])
243+
end
244+
end

src/syntax_tree.jl

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,11 @@ last_byte(node::AbstractSyntaxNode) = node.position + span(node) - 1
165165
Get the full source text of a node.
166166
"""
167167
function sourcetext(node::AbstractSyntaxNode)
168-
val_range = (node.position-1) .+ (1:span(node))
169-
view(node.source, val_range)
168+
view(node.source, range(node))
169+
end
170+
171+
function Base.range(node::AbstractSyntaxNode)
172+
(node.position-1) .+ (1:span(node))
170173
end
171174

172175
source_line(node::AbstractSyntaxNode) = source_line(node.source, node.position)
@@ -328,13 +331,12 @@ function child_position_span(node::SyntaxNode, path::Int...)
328331
n, n.position, span(n)
329332
end
330333

331-
"""
332-
Print the code, highlighting the part covered by `node` at tree `path`.
333-
"""
334-
function highlight(io::IO, code::String, node, path::Int...; color=(40,40,70))
335-
node, p, span = child_position_span(node, path...)
336-
q = p + span
337-
print(io, code[1:p-1])
338-
_printstyled(io, code[p:q-1]; bgcolor=color)
339-
print(io, code[q:end])
334+
function highlight(io::IO, node::SyntaxNode; kws...)
335+
highlight(io, node.source, range(node); kws...)
336+
end
337+
338+
function highlight(io::IO, source::SourceFile, node::GreenNode, path::Int...; kws...)
339+
_, p, span = child_position_span(node, path...)
340+
q = p + span - 1
341+
highlight(io, source, p:q; kws...)
340342
end

0 commit comments

Comments
 (0)