Skip to content

Commit 377ec4a

Browse files
authored
Also match argnames to validate methods (#108)
While working on TypedSyntax.jl it became apparent that CodeTracking sometimes returns spurious results. At least some of these arise from the recent support of anonymous functions, #102, which might in retrospect have been ill-considered. Rather than back that change out, this adopts a different resolution: validate the hits more carefully. The primary mechanism introduced here is to match not just the function name, but also the argument names. This can work even for anonymous functions, so we do not need to drop support for them. This also adds quite a few new tests. These additions would have passed before, but they proved valuable to ensure that the new argname-matching works sufficiently well. On TypedSyntax's "exhaustive.jl" test, this brings the number of failed cases (specifically, the `badmis`) from either 460 or 94 (depending on whether you include a few fixes in TypedSyntax) to just 2.
1 parent ef674c6 commit 377ec4a

File tree

6 files changed

+321
-46
lines changed

6 files changed

+321
-46
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "CodeTracking"
22
uuid = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2"
33
authors = ["Tim Holy <[email protected]>"]
4-
version = "1.2.2"
4+
version = "1.3.0"
55

66
[deps]
77
InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240"

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,8 @@ file/line info in the method itself if Revise isn't running.)
143143
144144
CodeTracking is perhaps best thought of as the "query" part of Revise.jl,
145145
providing a lightweight and stable API for gaining access to information it maintains internally.
146+
147+
## Limitations (without Revise)
148+
149+
- parsing sometimes starts on the wrong line. Line numbers are determined by counting `'\n'` in the source file, without parsing the contents. Consequently quoted- or in-code `'\n'` can mess up CodeTracking's notion of line numbering
150+
- default constructor methods for `struct`s are not found

src/CodeTracking.jl

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -243,24 +243,22 @@ function definition(::Type{String}, method::Method)
243243
src === nothing && return nothing
244244
src = replace(src, "\r"=>"")
245245
# Step forward to the definition of this method, keeping track of positions of newlines
246+
# Issue: in-code `'\n'`. To fix, presumably we'd have to parse the entire file.
246247
eol = isequal('\n')
247248
linestarts = Int[]
248249
istart = 1
249250
for _ = 1:line-1
250251
push!(linestarts, istart)
251252
istart = findnext(eol, src, istart) + 1
252253
end
254+
push!(linestarts, length(src) + 1)
253255
# Parse the function definition (hoping that we've found the right location to start)
254256
ex, iend = Meta.parse(src, istart; raise=false)
255-
iend = prevind(src, iend)
256-
if isfuncexpr(ex, methodname)
257-
iend = min(iend, lastindex(src))
258-
return clean_source(src[istart:iend]), line
259-
end
260-
# The function declaration was presumably on a previous line
257+
# The function declaration may have been on a previous line,
258+
# allow some slop
261259
lineindex = lastindex(linestarts)
262260
linestop = max(0, lineindex - 20)
263-
while !isfuncexpr(ex, methodname) && lineindex > linestop
261+
while !is_func_expr(ex, method) && lineindex > linestop
264262
istart = linestarts[lineindex]
265263
try
266264
ex, iend = Meta.parse(src, istart)
@@ -270,7 +268,7 @@ function definition(::Type{String}, method::Method)
270268
line -= 1
271269
end
272270
lineindex <= linestop && return nothing
273-
return clean_source(src[istart:iend-1]), line
271+
return clean_source(src[istart:prevind(src, iend)]), line
274272
end
275273

276274
function clean_source(src)

src/utils.jl

Lines changed: 181 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
# This should stay as the first method because it's used in a test
22
# (or change the test)
3-
function checkname(fdef::Expr, name)
4-
fproto = fdef.args[1]
5-
(fdef.head === :where || fdef.head == :(::)) && return checkname(fproto, name)
3+
function checkname(fdef::Expr, name) # this is now unused
64
fdef.head === :call || return false
5+
fproto = fdef.args[1]
76
if fproto isa Expr
8-
fproto.head == :(::) && return last(fproto.args) == name
9-
fproto.head == :curly && return fproto.args[1] === name
7+
fproto.head == :(::) && return last(fproto.args) === name # (obj::MyCallable)(x) = ...
8+
fproto.head == :curly && return fproto.args[1] === name # MyType{T}(x) = ...
109
# A metaprogramming-generated function
1110
fproto.head === :$ && return true # uncheckable, let's assume all is well
1211
# Is the check below redundant?
@@ -17,31 +16,128 @@ function checkname(fdef::Expr, name)
1716
isa(fproto, Symbol) || isa(fproto, QuoteNode) || isa(fproto, Expr) || return false
1817
return checkname(fproto, name)
1918
end
20-
checkname(fname::Symbol, name::Symbol) = begin
21-
fname === name && return true
22-
startswith(string(name), string('#', fname, '#')) && return true
23-
string(name) == string(fname, "##kw") && return true
24-
match(r"^#\d+$", string(name)) !== nothing && return true # support `f = x -> 2x`
25-
return false
19+
20+
function get_call_expr(@nospecialize(ex))
21+
while isa(ex, Expr) && ex.head (:where, :(::))
22+
ex = ex.args[1]
23+
end
24+
isexpr(ex, :call) && return ex
25+
return nothing
2626
end
27-
checkname(fname::Symbol, ::Nothing) = true
28-
checkname(fname::QuoteNode, name) = checkname(fname.value, name)
2927

30-
function isfuncexpr(ex, name=nothing)
28+
function get_func_expr(@nospecialize(ex))
29+
isa(ex, Expr) || return ex
3130
# Strip any macros that wrap the method definition
32-
if ex isa Expr && ex.head === :toplevel
31+
while isa(ex, Expr) && ex.head (:toplevel, :macrocall, :global, :local)
32+
ex.head == :macrocall && length(ex.args) < 3 && return ex
3333
ex = ex.args[end]
3434
end
35-
while ex isa Expr && ex.head === :macrocall && length(ex.args) >= 3
36-
ex = ex.args[end]
35+
isa(ex, Expr) || return ex
36+
if ex.head == :(=) && length(ex.args) == 2
37+
child1, child2 = ex.args
38+
isexpr(get_call_expr(child1), :call) && return ex
39+
isexpr(child2, :(->)) && return child2
3740
end
41+
return ex
42+
end
43+
44+
function is_func_expr(@nospecialize(ex))
3845
isa(ex, Expr) || return false
39-
if ex.head === :function || ex.head === :(=)
40-
return checkname(ex.args[1], name)
46+
ex.head (:function, :(->)) && return true
47+
if ex.head == :(=) && length(ex.args) == 2
48+
child1 = ex.args[1]
49+
isexpr(get_call_expr(child1), :call) && return true
4150
end
4251
return false
4352
end
4453

54+
function is_func_expr(@nospecialize(ex), name::Symbol)
55+
ex = get_func_expr(ex)
56+
is_func_expr(ex) || return false
57+
return checkname(get_call_expr(ex.args[1]), name)
58+
end
59+
60+
function is_func_expr(@nospecialize(ex), meth::Method)
61+
ex = get_func_expr(ex)
62+
is_func_expr(ex) || return false
63+
fname = nothing
64+
if ex.head == :(->)
65+
exargs = ex.args[1]
66+
if isexpr(exargs, :tuple)
67+
exargs = exargs.args
68+
elseif (isa(exargs, Expr) && exargs.head (:(::), :.)) || isa(exargs, Symbol)
69+
exargs = [exargs]
70+
elseif isa(exargs, Expr)
71+
return false
72+
end
73+
else
74+
callex = get_call_expr(ex.args[1])
75+
isexpr(callex, :call) || return false
76+
fname = callex.args[1]
77+
modified = true
78+
while modified
79+
modified = false
80+
if isexpr(fname, :curly) # where clause
81+
fname = fname.args[1]
82+
modified = true
83+
end
84+
if isexpr(fname, :., 2) # module-qualified
85+
fname = fname.args[2]
86+
@assert isa(fname, QuoteNode)
87+
fname = fname.value
88+
modified = true
89+
end
90+
if isexpr(fname, :(::))
91+
fname = fname.args[end]
92+
modified = true
93+
end
94+
end
95+
if !(isa(fname, Symbol) && is_gensym(fname)) && !isexpr(fname, :$)
96+
if fname === :Type && isexpr(ex.args[1], :where) && isexpr(callex.args[1], :(::)) && isexpr(callex.args[1].args[end], :curly)
97+
Tsym = callex.args[1].args[end].args[2]
98+
for wheretyp in ex.args[1].args[2:end]
99+
@assert isexpr(wheretyp, :(<:))
100+
if Tsym == wheretyp.args[1]
101+
fname = wheretyp.args[2]
102+
break
103+
end
104+
end
105+
end
106+
# match the function name
107+
fname === strip_gensym(meth.name) || return false
108+
end
109+
exargs = callex.args[2:end]
110+
end
111+
# match the argnames
112+
if !isempty(exargs) && isexpr(first(exargs), :parameters)
113+
popfirst!(exargs) # don't match kwargs
114+
end
115+
margs = Base.method_argnames(meth)
116+
_, idx = kwmethod_basename(meth)
117+
if idx > 0
118+
margs = margs[idx:end]
119+
end
120+
for (arg, marg) in zip(exargs, margs[2:end])
121+
aname = get_argname(arg)
122+
aname === :_ && continue
123+
aname === marg || (aname === Symbol("#unused#") && marg === Symbol("")) || return false
124+
end
125+
return true # this will match any fcn `() -> ...`, but file/line is the only thing we have
126+
end
127+
128+
function get_argname(@nospecialize(ex))
129+
isa(ex, Symbol) && return ex
130+
isexpr(ex, :(::), 2) && return get_argname(ex.args[1]) # type-asserted
131+
isexpr(ex, :(::), 1) && return Symbol("#unused#") # nameless args (e.g., `::Type{String}`)
132+
isexpr(ex, :kw) && return get_argname(ex.args[1]) # default value
133+
isexpr(ex, :(=)) && return get_argname(ex.args[1]) # default value inside `@nospecialize`
134+
isexpr(ex, :macrocall) && return get_argname(ex.args[end]) # @nospecialize
135+
isexpr(ex, :...) && return get_argname(only(ex.args)) # varargs
136+
isexpr(ex, :tuple) && return Symbol("") # tuple-destructuring
137+
dump(ex)
138+
error("unexpected argument ", ex)
139+
end
140+
45141
function linerange(def::Expr)
46142
start, haslinestart = findline(def, identity)
47143
stop, haslinestop = findline(def, Iterators.reverse)
@@ -70,6 +166,72 @@ Base.convert(::Type{LineNumberNode}, lin::LineInfoNode) = LineNumberNode(lin.lin
70166

71167
# This regex matches the pseudo-file name of a REPL history entry.
72168
const rREPL = r"^REPL\[(\d+)\]$"
169+
# Match anonymous function names
170+
const rexfanon = r"^#\d+$"
171+
# Match kwfunc method names
172+
const rexkwfunc = r"^#.*##kw$"
173+
174+
is_gensym(s::Symbol) = is_gensym(string(s))
175+
is_gensym(str::AbstractString) = startswith(str, '#')
176+
177+
strip_gensym(s::Symbol) = strip_gensym(string(s))
178+
function strip_gensym(str::AbstractString)
179+
if startswith(str, '#')
180+
idx = findnext('#', str, 2)
181+
if idx !== nothing
182+
return Symbol(str[2:idx-1])
183+
end
184+
end
185+
endswith(str, "##kw") && return Symbol(str[1:end-4])
186+
return Symbol(str)
187+
end
188+
189+
if isdefined(Core, :kwcall)
190+
is_kw_call(m::Method) = Base.unwrap_unionall(m.sig).parameters[1] === typeof(Core.kwcall)
191+
else
192+
function is_kw_call(m::Method)
193+
T = Base.unwrap_unionall(m.sig).parameters[1]
194+
return match(rexkwfunc, string(T.name.name)) !== nothing
195+
end
196+
end
197+
198+
# is_body_fcn(m::Method, basename::Symbol) = match(Regex("^#$basename#\\d+\$"), string(m.name)) !== nothing
199+
# function is_body_fcn(m::Method, basename::Expr)
200+
# basename.head == :. || return false
201+
# return is_body_fcn(m, get_basename(basename))
202+
# end
203+
# is_body_fcn(m::Method, ::Nothing) = false
204+
# function get_basename(basename::Expr)
205+
# bn = basename.args[end]
206+
# @assert isa(bn, QuoteNode)
207+
# return is_body_fcn(m, bn.value)
208+
# end
209+
210+
function kwmethod_basename(meth::Method)
211+
name = meth.name
212+
sname = string(name)
213+
mtch = match(r"^(.*)##kw$", sname)
214+
if mtch === nothing
215+
mtch = match(r"^#+(.*)#", sname)
216+
end
217+
name = mtch === nothing ? name : Symbol(only(mtch.captures))
218+
ftypname = Symbol(string('#', name))
219+
idx = findfirst(Base.unwrap_unionall(meth.sig).parameters) do @nospecialize(T)
220+
if isa(T, DataType)
221+
Tname = T.name.name
222+
if Tname === :Type
223+
p1 = Base.unwrap_unionall(T.parameters[1])
224+
Tname = isa(p1, DataType) ? p1.name.name :
225+
isa(p1, TypeVar) ? p1.name : error("unexpected type ", typeof(p1), "for ", meth)
226+
return Tname == name
227+
end
228+
return ftypname === Tname
229+
end
230+
false
231+
end
232+
idx === nothing && return name, 0
233+
return name, idx
234+
end
73235

74236
"""
75237
src = src_from_file_or_REPL(origin::AbstractString, repl = Base.active_repl)

0 commit comments

Comments
 (0)