Skip to content

Commit 594544d

Browse files
authored
REPL: warn on non-owning qualified accesses (#54872)
* Accessing names from other modules can be dangerous, because those names may only be in that module by happenstance, e.g. a name exported by Base * the keyword `public` encourages more qualified accesses, increasing the risk of accidentally accessing a name that isn't part of that module on purpose * ExplicitImports.jl can catch this in a package context, but this requires opting in. Folks who might not be writing packages or might not be using dev tooling like ExplicitImports can easily get caught off guard by these accesses (and might be less familiar with the issue than package developers) * using a REPL AST transform we can emit warnings when we notice this happening in the REPL
1 parent ed987f2 commit 594544d

File tree

5 files changed

+250
-2
lines changed

5 files changed

+250
-2
lines changed

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ Standard library changes
129129
complete names that have been explicitly `using`-ed. ([#54610])
130130
- REPL completions can now complete input lines like `[import|using] Mod: xxx|` e.g.
131131
complete `using Base.Experimental: @op` to `using Base.Experimental: @opaque`. ([#54719])
132+
- the REPL will now warn if it detects a name is being accessed from a module which does not define it (nor has a submodule which defines it),
133+
and for which the name is not public in that module. For example, `map` is defined in Base, and executing `LinearAlgebra.map`
134+
in the REPL will now issue a warning the first time occurs. ([#54872])
132135

133136
#### SuiteSparse
134137

stdlib/REPL/Project.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ StyledStrings = "f489334b-da3d-4c2e-b8f0-e476e12c162b"
1010
Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
1111

1212
[extras]
13+
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
1314
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
1415
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
1516

1617
[targets]
17-
test = ["Test", "Random"]
18+
test = ["Logging", "Test", "Random"]

stdlib/REPL/src/REPL.jl

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,129 @@ end
206206
# Temporary alias until Documenter updates
207207
const softscope! = softscope
208208

209-
const repl_ast_transforms = Any[softscope] # defaults for new REPL backends
209+
function print_qualified_access_warning(mod::Module, owner::Module, name::Symbol)
210+
@warn string(name, " is defined in ", owner, " and is not public in ", mod) maxlog = 1 _id = string("repl-warning-", mod, "-", owner, "-", name) _line = nothing _file = nothing _module = nothing
211+
end
212+
213+
function has_ancestor(query::Module, target::Module)
214+
query == target && return true
215+
while true
216+
next = parentmodule(query)
217+
next == target && return true
218+
next == query && return false
219+
query = next
220+
end
221+
end
222+
223+
retrieve_modules(::Module, ::Any) = (nothing,)
224+
function retrieve_modules(current_module::Module, mod_name::Symbol)
225+
mod = try
226+
getproperty(current_module, mod_name)
227+
catch
228+
return (nothing,)
229+
end
230+
return (mod isa Module ? mod : nothing,)
231+
end
232+
retrieve_modules(current_module::Module, mod_name::QuoteNode) = retrieve_modules(current_module, mod_name.value)
233+
function retrieve_modules(current_module::Module, mod_expr::Expr)
234+
if Meta.isexpr(mod_expr, :., 2)
235+
current_module = retrieve_modules(current_module, mod_expr.args[1])[1]
236+
current_module === nothing && return (nothing,)
237+
return (current_module, retrieve_modules(current_module, mod_expr.args[2])...)
238+
else
239+
return (nothing,)
240+
end
241+
end
242+
243+
add_locals!(locals, ast::Any) = nothing
244+
function add_locals!(locals, ast::Expr)
245+
for arg in ast.args
246+
add_locals!(locals, arg)
247+
end
248+
return nothing
249+
end
250+
function add_locals!(locals, ast::Symbol)
251+
push!(locals, ast)
252+
return nothing
253+
end
254+
255+
function collect_names_to_warn!(warnings, locals, current_module::Module, ast)
256+
ast isa Expr || return
257+
258+
# don't recurse through module definitions
259+
ast.head === :module && return
260+
261+
if Meta.isexpr(ast, :., 2)
262+
mod_name, name_being_accessed = ast.args
263+
# retrieve the (possibly-nested) module being named here
264+
mods = retrieve_modules(current_module, mod_name)
265+
all(x -> x isa Module, mods) || return
266+
outer_mod = first(mods)
267+
mod = last(mods)
268+
if name_being_accessed isa QuoteNode
269+
name_being_accessed = name_being_accessed.value
270+
end
271+
name_being_accessed isa Symbol || return
272+
owner = try
273+
which(mod, name_being_accessed)
274+
catch
275+
return
276+
end
277+
# if `owner` is a submodule of `mod`, then don't warn. E.g. the name `parse` is present in the module `JSON`
278+
# but is owned by `JSON.Parser`; we don't warn if it is accessed as `JSON.parse`.
279+
has_ancestor(owner, mod) && return
280+
# Don't warn if the name is public in the module we are accessing it
281+
Base.ispublic(mod, name_being_accessed) && return
282+
# Don't warn if accessing names defined in Core from Base if they are present in Base (e.g. `Base.throw`).
283+
mod === Base && Base.ispublic(Core, name_being_accessed) && return
284+
push!(warnings, (; outer_mod, mod, owner, name_being_accessed))
285+
# no recursion
286+
return
287+
elseif Meta.isexpr(ast, :(=), 2)
288+
lhs, rhs = ast.args
289+
# any symbols we find on the LHS we will count as local. This can potentially be overzealous,
290+
# but we want to avoid false positives (unnecessary warnings) more than false negatives.
291+
add_locals!(locals, lhs)
292+
# we'll recurse into the RHS only
293+
return collect_names_to_warn!(warnings, locals, current_module, rhs)
294+
elseif Meta.isexpr(ast, :function) && length(ast.args) >= 1
295+
296+
if Meta.isexpr(ast.args[1], :call, 2)
297+
func_name, func_args = ast.args[1].args
298+
# here we have a function definition and are inspecting it's arguments for local variables.
299+
# we will error on the conservative side by adding all symbols we find (regardless if they are local variables or possibly-global default values)
300+
add_locals!(locals, func_args)
301+
end
302+
# fall through to general recursion
303+
end
304+
305+
for arg in ast.args
306+
collect_names_to_warn!(warnings, locals, current_module, arg)
307+
end
308+
309+
return nothing
310+
end
311+
312+
function collect_qualified_access_warnings(current_mod, ast)
313+
warnings = Set()
314+
locals = Set{Symbol}()
315+
collect_names_to_warn!(warnings, locals, current_mod, ast)
316+
filter!(warnings) do (; outer_mod)
317+
nameof(outer_mod) locals
318+
end
319+
return warnings
320+
end
321+
322+
function warn_on_non_owning_accesses(current_mod, ast)
323+
warnings = collect_qualified_access_warnings(current_mod, ast)
324+
for (; outer_mod, mod, owner, name_being_accessed) in warnings
325+
print_qualified_access_warning(mod, owner, name_being_accessed)
326+
end
327+
return ast
328+
end
329+
warn_on_non_owning_accesses(ast) = warn_on_non_owning_accesses(REPL.active_module(), ast)
330+
331+
const repl_ast_transforms = Any[softscope, warn_on_non_owning_accesses] # defaults for new REPL backends
210332

211333
# Allows an external package to add hooks into the code loading.
212334
# The hook should take a Vector{Symbol} of package names and

stdlib/REPL/src/precompile.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ $UP_ARROW$DOWN_ARROW$CTRL_C
5858
f(x) = x03
5959
f(1,2)
6060
[][1]
61+
Base.Iterators.minimum
6162
cd("complete_path\t\t$CTRL_C
6263
"""
6364

stdlib/REPL/test/repl.jl

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Test
44
using REPL
55
using Random
6+
using Logging
67
import REPL.LineEdit
78
using Markdown
89

@@ -1400,6 +1401,126 @@ end
14001401
Base.wait(backend.backend_task)
14011402
end
14021403

1404+
# Mimic of JSON.jl's structure
1405+
module JSON54872
1406+
1407+
module Parser
1408+
export parse
1409+
function parse end
1410+
end # Parser
1411+
1412+
using .Parser: parse
1413+
end # JSON54872
1414+
1415+
# Test the public mechanism
1416+
module JSON54872_public
1417+
public tryparse
1418+
end # JSON54872_public
1419+
1420+
@testset "warn_on_non_owning_accesses AST transform" begin
1421+
@test REPL.has_ancestor(JSON54872.Parser, JSON54872)
1422+
@test !REPL.has_ancestor(JSON54872, JSON54872.Parser)
1423+
1424+
# JSON54872.Parser owns `parse`
1425+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1426+
JSON54872.Parser.parse
1427+
end)
1428+
@test isempty(warnings)
1429+
1430+
# A submodule of `JSON54872` owns `parse`
1431+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1432+
JSON54872.parse
1433+
end)
1434+
@test isempty(warnings)
1435+
1436+
# `JSON54872` does not own `tryparse` (nor is it public)
1437+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1438+
JSON54872.tryparse
1439+
end)
1440+
@test length(warnings) == 1
1441+
@test only(warnings).owner == Base
1442+
@test only(warnings).name_being_accessed == :tryparse
1443+
1444+
# Same for nested access
1445+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1446+
JSON54872.Parser.tryparse
1447+
end)
1448+
@test length(warnings) == 1
1449+
@test only(warnings).owner == Base
1450+
@test only(warnings).name_being_accessed == :tryparse
1451+
1452+
test_logger = TestLogger()
1453+
with_logger(test_logger) do
1454+
REPL.warn_on_non_owning_accesses(@__MODULE__, :(JSON54872.tryparse))
1455+
REPL.warn_on_non_owning_accesses(@__MODULE__, :(JSON54872.tryparse))
1456+
end
1457+
# only 1 logging statement emitted thanks to `maxlog` mechanism
1458+
@test length(test_logger.logs) == 1
1459+
record = only(test_logger.logs)
1460+
@test record.level == Warn
1461+
@test record.message == "tryparse is defined in Base and is not public in $JSON54872"
1462+
1463+
# However JSON54872_public has `tryparse` declared public
1464+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1465+
JSON54872_public.tryparse
1466+
end)
1467+
@test isempty(warnings)
1468+
1469+
# Now let us test some tricky cases
1470+
# No warning since `JSON54872` is local (LHS of `=`)
1471+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1472+
let JSON54872 = (; tryparse=1)
1473+
JSON54872.tryparse
1474+
end
1475+
end)
1476+
@test isempty(warnings)
1477+
1478+
# No warning for nested local access either
1479+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1480+
let JSON54872 = (; Parser = (; tryparse=1))
1481+
JSON54872.Parser.tryparse
1482+
end
1483+
end)
1484+
@test isempty(warnings)
1485+
1486+
# No warning since `JSON54872` is local (long-form function arg)
1487+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1488+
function f(JSON54872=(; tryparse))
1489+
JSON54872.tryparse
1490+
end
1491+
end)
1492+
@test isempty(warnings)
1493+
1494+
# No warning since `JSON54872` is local (short-form function arg)
1495+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1496+
f(JSON54872=(; tryparse)) = JSON54872.tryparse
1497+
end)
1498+
@test isempty(warnings)
1499+
1500+
# No warning since `JSON54872` is local (long-form anonymous function)
1501+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1502+
function (JSON54872=(; tryparse))
1503+
JSON54872.tryparse
1504+
end
1505+
end)
1506+
@test isempty(warnings)
1507+
1508+
# No warning since `JSON54872` is local (short-form anonymous function)
1509+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1510+
(JSON54872 = (; tryparse)) -> begin
1511+
JSON54872.tryparse
1512+
end
1513+
end)
1514+
@test isempty(warnings)
1515+
1516+
# false-negative: missing warning
1517+
warnings = REPL.collect_qualified_access_warnings(@__MODULE__, quote
1518+
let JSON54872 = JSON54872
1519+
JSON54872.tryparse
1520+
end
1521+
end)
1522+
@test_broken !isempty(warnings)
1523+
end
14031524

14041525
backend = REPL.REPLBackend()
14051526
frontend_task = @async begin

0 commit comments

Comments
 (0)