From c6b8f98c1513d133560cfb12a44e9b56e8a50aac Mon Sep 17 00:00:00 2001 From: Eric Hanson <5846501+ericphanson@users.noreply.github.com> Date: Sun, 11 Jan 2026 14:23:13 +0100 Subject: [PATCH 1/4] Add repro for issue #81 cmd interpolation Co-authored-by: Codex --- test/issue_81.jl | 20 ++++++++++++++++++++ test/issue_81_test.jl | 9 +++++++++ test/runtests.jl | 4 ++++ 3 files changed, 33 insertions(+) create mode 100644 test/issue_81.jl create mode 100644 test/issue_81_test.jl diff --git a/test/issue_81.jl b/test/issue_81.jl new file mode 100644 index 00000000..b37ab602 --- /dev/null +++ b/test/issue_81.jl @@ -0,0 +1,20 @@ +module UnzipExporter81 + +export unzip + +unzip() = "unzip" + +end # module UnzipExporter81 + +module CmdInterpolationUsesImport + +using ..UnzipExporter81: unzip + +function register_steelProfile() + function post_fetch_method(file) + run(`$(unzip()) -q $file`) + rm(file) + end +end + +end # module CmdInterpolationUsesImport diff --git a/test/issue_81_test.jl b/test/issue_81_test.jl new file mode 100644 index 00000000..2126b07d --- /dev/null +++ b/test/issue_81_test.jl @@ -0,0 +1,9 @@ +using Test +using ExplicitImports + +issue_path = joinpath(@__DIR__, "issue_81.jl") +include(issue_path) + +@testset "Cmd interpolation uses explicit imports (#81)" begin + @test check_no_stale_explicit_imports(CmdInterpolationUsesImport, issue_path) === nothing +end diff --git a/test/runtests.jl b/test/runtests.jl index 5d1a14d1..4e9ed034 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -109,6 +109,10 @@ include("issue_140.jl") include("issue_111_test.jl") end + @testset "Cmd interpolation uses explicit imports (#81)" begin + include("issue_81_test.jl") + end + @testset "module aliases (#106)" begin # https://github.com/JuliaTesting/ExplicitImports.jl/issues/106 ret = Dict(improper_explicit_imports(ModAlias, "module_alias.jl")) From da3d0a74cbbb082203063fb896c0411ac0f28145 Mon Sep 17 00:00:00 2001 From: Eric Hanson <5846501+ericphanson@users.noreply.github.com> Date: Sun, 11 Jan 2026 14:56:52 +0100 Subject: [PATCH 2/4] Handle cmd interpolation identifiers Co-authored-by: Codex --- src/get_names_used.jl | 157 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/src/get_names_used.jl b/src/get_names_used.jl index 28b1db16..50143d17 100644 --- a/src/get_names_used.jl +++ b/src/get_names_used.jl @@ -769,6 +769,157 @@ function analyze_name(leaf; debug=false) end end +function is_escaped_by_backslash(str, idx) + count = 0 + j = prevind(str, idx) + while j >= firstindex(str) + str[j] == '\\' || break + count += 1 + j = prevind(str, j) + end + return isodd(count) +end + +is_identifier_start(c::Char) = Base.isidentifier(string(c)) +is_identifier_char(c::Char) = Base.isidentifier(string('_', c)) + +function skip_quoted(str, idx, quote_char) + last = lastindex(str) + if quote_char == '"' && + idx <= prevind(str, last, 2) && + str[nextind(str, idx)] == '"' && + str[nextind(str, idx, 2)] == '"' + i = nextind(str, idx, 3) + while i <= prevind(str, last, 2) + if str[i] == '"' && + str[nextind(str, i)] == '"' && + str[nextind(str, i, 2)] == '"' + return nextind(str, i, 3) + end + i = nextind(str, i) + end + return nextind(str, last) + end + + i = nextind(str, idx) + while i <= last + if str[i] == quote_char && !is_escaped_by_backslash(str, i) + return nextind(str, i) + end + i = nextind(str, i) + end + return nextind(str, last) +end + +function skip_comment(str, idx) + i = nextind(str, idx) + last = lastindex(str) + while i <= last && str[i] != '\n' + i = nextind(str, i) + end + return i +end + +function find_matching_paren(str, start) + depth = 1 + i = start + last = lastindex(str) + while i <= last + c = str[i] + if c == '"' || c == '\'' || c == '`' + i = skip_quoted(str, i, c) + continue + elseif c == '#' + i = skip_comment(str, i) + continue + elseif c == '(' + depth += 1 + elseif c == ')' + depth -= 1 + depth == 0 && return i + end + i = nextind(str, i) + end + return nothing +end + +function cmdstring_interpolations(str::AbstractString) + exprs = Any[] + i = firstindex(str) + last = lastindex(str) + while i <= last + if str[i] == '$' && !is_escaped_by_backslash(str, i) + next_idx = nextind(str, i) + if next_idx <= last && str[next_idx] == '(' + start = nextind(str, next_idx) + end_idx = find_matching_paren(str, start) + if end_idx !== nothing + expr = Meta.parse(str[start:prevind(str, end_idx)]; raise=false) + if !(expr isa Expr && expr.head == :error) + push!(exprs, expr) + end + i = nextind(str, end_idx) + continue + end + elseif next_idx <= last && is_identifier_start(str[next_idx]) + j = nextind(str, next_idx) + while j <= last && is_identifier_char(str[j]) + j = nextind(str, j) + end + push!(exprs, Symbol(str[next_idx:prevind(str, j)])) + i = j + continue + end + end + i = nextind(str, i) + end + return exprs +end + +function append_cmdstring_interpolations!(per_usage_info, leaf) + exprs = cmdstring_interpolations(get_val(leaf)) + isempty(exprs) && return nothing + + outer = analyze_name(leaf) + outer_scope_path = outer.scope_path + outer_module_path = outer.module_path + wrapper = nodevalue(leaf) + location = location_str(wrapper) + + for expr in exprs + expr_src = sprint(Base.show_unquoted, expr) + parsed = JuliaSyntax.parseall(JuliaSyntax.SyntaxNode, expr_src; ignore_warnings=true) + expr_wrapper = SyntaxNodeWrapper(parsed, wrapper.file, wrapper.bad_locations) + cursor = TreeCursor(expr_wrapper) + for expr_leaf in Leaves(cursor) + if nodevalue(expr_leaf) isa SkippedFile + continue + end + (kind(expr_leaf) in (K"Identifier", K"MacroName", K"StringMacroName"))::Bool || + continue + parents_match(expr_leaf, (K"quote",)) && + !parents_match(expr_leaf, (K"quote", K".")) && continue + + name = get_val(expr_leaf) + qualified_by = qualifying_module(expr_leaf) + import_type = analyze_import_type(expr_leaf) + explicitly_imported_by = import_type == :import_RHS ? get_import_lhs(expr_leaf) : nothing + inner = analyze_name(expr_leaf) + scope_path = vcat(inner.scope_path, outer_scope_path) + push!(per_usage_info, + (; name, + qualified_by, + import_type, + explicitly_imported_by, + location, + inner..., + module_path=outer_module_path, + scope_path)) + end + end + return outer_module_path +end + """ analyze_all_names(file) @@ -822,6 +973,12 @@ function analyze_all_names(file) continue end + if kind(leaf) == K"CmdString" + mod_path = append_cmdstring_interpolations!(per_usage_info, leaf) + mod_path === nothing || push!(seen_modules, mod_path) + continue + end + # if we don't find any identifiers (or macro names) in a module, I think it's OK to mark it as # "not-seen"? Otherwise we need to analyze every leaf, not just the identifiers # and that sounds slow. Seems like a very rare edge case to have no identifiers... From de0385d26c8dd575cac2309b2702730308a73962 Mon Sep 17 00:00:00 2001 From: Eric Hanson <5846501+ericphanson@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:13:18 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=80=A2=20Cmd=20interpolation=20fix:=20pa?= =?UTF-8?q?rse=20full=20backtick=20literal=20via=20shell=5Fparse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual $-scanning for cmdstrings with a single Meta.parse + Base.shell_parse pass to extract interpolated expressions, and dedupe per cmdstring node. This aligns with Julia’s cmd parsing rules and removes brittle string handling in get_names_used. Co-authored-by: Codex codex@openai.com (codex@openai.com) --- src/get_names_used.jl | 153 +++++++++++++++++------------------------- 1 file changed, 61 insertions(+), 92 deletions(-) diff --git a/src/get_names_used.jl b/src/get_names_used.jl index 50143d17..6f8a4fb4 100644 --- a/src/get_names_used.jl +++ b/src/get_names_used.jl @@ -769,115 +769,81 @@ function analyze_name(leaf; debug=false) end end -function is_escaped_by_backslash(str, idx) - count = 0 - j = prevind(str, idx) - while j >= firstindex(str) - str[j] == '\\' || break - count += 1 - j = prevind(str, j) +function cmdstring_parent(leaf) + node = leaf + while true + kind(node) == K"cmdstring" && return node + has_parent(node) || return nothing + node = parent(node) end - return isodd(count) end -is_identifier_start(c::Char) = Base.isidentifier(string(c)) -is_identifier_char(c::Char) = Base.isidentifier(string('_', c)) - -function skip_quoted(str, idx, quote_char) - last = lastindex(str) - if quote_char == '"' && - idx <= prevind(str, last, 2) && - str[nextind(str, idx)] == '"' && - str[nextind(str, idx, 2)] == '"' - i = nextind(str, idx, 3) - while i <= prevind(str, last, 2) - if str[i] == '"' && - str[nextind(str, i)] == '"' && - str[nextind(str, i, 2)] == '"' - return nextind(str, i, 3) - end - i = nextind(str, i) - end - return nextind(str, last) - end - - i = nextind(str, idx) - while i <= last - if str[i] == quote_char && !is_escaped_by_backslash(str, i) - return nextind(str, i) - end - i = nextind(str, i) +function unwrap_toplevel_expr(expr) + if expr isa Expr && expr.head == :toplevel && length(expr.args) == 1 + return expr.args[1] end - return nextind(str, last) + return expr end -function skip_comment(str, idx) - i = nextind(str, idx) - last = lastindex(str) - while i <= last && str[i] != '\n' - i = nextind(str, i) +function cmdstring_string_literal(expr) + expr = unwrap_toplevel_expr(expr) + if expr isa Expr && expr.head == :macrocall + for arg in expr.args[2:end] + arg isa LineNumberNode && continue + if arg isa String + return arg + elseif arg isa Expr && arg.head == :string + all(x -> x isa String, arg.args) || return nothing + return join(arg.args) + end + end + return nothing + elseif expr isa String + return expr end - return i + return nothing end -function find_matching_paren(str, start) - depth = 1 - i = start - last = lastindex(str) - while i <= last - c = str[i] - if c == '"' || c == '\'' || c == '`' - i = skip_quoted(str, i, c) - continue - elseif c == '#' - i = skip_comment(str, i) - continue - elseif c == '(' - depth += 1 - elseif c == ')' - depth -= 1 - depth == 0 && return i +function collect_shell_interpolations!(exprs, part) + if part isa Expr && part.head == :tuple + for item in part.args + collect_shell_interpolations!(exprs, item) end - i = nextind(str, i) + elseif part isa AbstractString + return nothing + else + push!(exprs, part) end return nothing end -function cmdstring_interpolations(str::AbstractString) - exprs = Any[] - i = firstindex(str) - last = lastindex(str) - while i <= last - if str[i] == '$' && !is_escaped_by_backslash(str, i) - next_idx = nextind(str, i) - if next_idx <= last && str[next_idx] == '(' - start = nextind(str, next_idx) - end_idx = find_matching_paren(str, start) - if end_idx !== nothing - expr = Meta.parse(str[start:prevind(str, end_idx)]; raise=false) - if !(expr isa Expr && expr.head == :error) - push!(exprs, expr) - end - i = nextind(str, end_idx) - continue - end - elseif next_idx <= last && is_identifier_start(str[next_idx]) - j = nextind(str, next_idx) - while j <= last && is_identifier_char(str[j]) - j = nextind(str, j) - end - push!(exprs, Symbol(str[next_idx:prevind(str, j)])) - i = j - continue - end - end - i = nextind(str, i) +# Parse the full cmd literal once so interpolation extraction follows Julia's grammar. +function cmdstring_interpolations_from_source(cmd_src::AbstractString) + expr = Meta.parse(cmd_src; raise=false) + expr isa Expr && expr.head == :error && return Any[] + cmd_str = cmdstring_string_literal(expr) + cmd_str === nothing && return Any[] + parsed = try + Base.shell_parse(cmd_str; special=Base.shell_special)[1] + catch + return Any[] end + exprs = Any[] + collect_shell_interpolations!(exprs, parsed) return exprs end -function append_cmdstring_interpolations!(per_usage_info, leaf) - exprs = cmdstring_interpolations(get_val(leaf)) +function append_cmdstring_interpolations!(per_usage_info, leaf, processed_cmdstrings) + cmd_node = cmdstring_parent(leaf) + cmd_node === nothing && return nothing + cmd_id = objectid(js_node(cmd_node)) + if cmd_id in processed_cmdstrings + return nothing + end + push!(processed_cmdstrings, cmd_id) + + cmd_src = String(JuliaSyntax.sourcetext(js_node(cmd_node))) + exprs = cmdstring_interpolations_from_source(cmd_src) isempty(exprs) && return nothing outer = analyze_name(leaf) @@ -964,6 +930,7 @@ function analyze_all_names(file) # safe to analyze. seen_modules = Set{Vector{Symbol}}() tainted_modules = Set{Vector{Symbol}}() + processed_cmdstrings = Set{UInt}() for leaf in Leaves(cursor) if nodevalue(leaf) isa SkippedFile @@ -974,7 +941,9 @@ function analyze_all_names(file) end if kind(leaf) == K"CmdString" - mod_path = append_cmdstring_interpolations!(per_usage_info, leaf) + mod_path = append_cmdstring_interpolations!(per_usage_info, + leaf, + processed_cmdstrings) mod_path === nothing || push!(seen_modules, mod_path) continue end From 75b69293e48de74a1cf95a41866ce59d0688d081 Mon Sep 17 00:00:00 2001 From: Eric Hanson <5846501+ericphanson@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:15:40 +0100 Subject: [PATCH 4/4] more tests --- test/issue_81.jl | 40 ++++++++++++++++++++++++++++++++++++++++ test/issue_81_test.jl | 6 ++++++ 2 files changed, 46 insertions(+) diff --git a/test/issue_81.jl b/test/issue_81.jl index b37ab602..8756c41b 100644 --- a/test/issue_81.jl +++ b/test/issue_81.jl @@ -18,3 +18,43 @@ function register_steelProfile() end end # module CmdInterpolationUsesImport + +module CmdInterpolationUsesImportQuoted + +using ..UnzipExporter81: unzip + +function register_quoted(file) + run(`"$(unzip())" -q "$file"`) +end + +end # module CmdInterpolationUsesImportQuoted + +module CmdInterpolationUsesImportNested + +using ..UnzipExporter81: unzip + +function register_nested(file) + run(`$(unzip()) -q $(joinpath(dirname(file), basename(file)))`) +end + +end # module CmdInterpolationUsesImportNested + +module CmdInterpolationUsesImportAdjacent + +using ..UnzipExporter81: unzip + +function register_adjacent(file) + run(`$(unzip()) --dest=$(basename(file))_out`) +end + +end # module CmdInterpolationUsesImportAdjacent + +module CmdInterpolationUsesImportQuotedLiteral + +using ..UnzipExporter81: unzip + +function register_quoted_literal(file) + run(`echo '$unzip' $file`) +end + +end # module CmdInterpolationUsesImportQuotedLiteral diff --git a/test/issue_81_test.jl b/test/issue_81_test.jl index 2126b07d..cd2018b7 100644 --- a/test/issue_81_test.jl +++ b/test/issue_81_test.jl @@ -6,4 +6,10 @@ include(issue_path) @testset "Cmd interpolation uses explicit imports (#81)" begin @test check_no_stale_explicit_imports(CmdInterpolationUsesImport, issue_path) === nothing + @test check_no_stale_explicit_imports(CmdInterpolationUsesImportQuoted, issue_path) === nothing + @test check_no_stale_explicit_imports(CmdInterpolationUsesImportNested, issue_path) === nothing + @test check_no_stale_explicit_imports(CmdInterpolationUsesImportAdjacent, issue_path) === nothing + @test_throws ExplicitImports.StaleImportsException check_no_stale_explicit_imports( + CmdInterpolationUsesImportQuotedLiteral, + issue_path) end