Skip to content

Commit 2d68286

Browse files
committed
Fix string escaping in REPL completion of paths
REPL completion of paths within strings need to be escaped according to the usual escaping rules, and delimited by the starting " rather than whitespace. This differs from completion of paths within cmd backticks which need to be escaped according to shell escaping rules. Separate these cases and fix string escaping. This was found because JuliaSyntax emits an Expr(:error) rather than Expr(:incomplete) for paths inside strings with invalid escape sequences before whitespace.
1 parent 964f0d6 commit 2d68286

File tree

2 files changed

+98
-31
lines changed

2 files changed

+98
-31
lines changed

stdlib/REPL/src/REPLCompletions.jl

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,10 @@ function complete_keyword(s::Union{String,SubString{String}})
232232
Completion[KeywordCompletion(kw) for kw in sorted_keywords[r]]
233233
end
234234

235-
function complete_path(path::AbstractString, pos::Int; use_envpath=false, shell_escape=false)
235+
function complete_path(path::AbstractString, pos::Int;
236+
use_envpath=false, shell_escape=false,
237+
string_escape=false)
238+
@assert !(shell_escape && string_escape)
236239
if Base.Sys.isunix() && occursin(r"^~(?:/|$)", path)
237240
# if the path is just "~", don't consider the expanded username as a prefix
238241
if path == "~"
@@ -259,9 +262,9 @@ function complete_path(path::AbstractString, pos::Int; use_envpath=false, shell_
259262
matches = Set{String}()
260263
for file in files
261264
if startswith(file, prefix)
262-
id = try isdir(joinpath(dir, file)) catch; false end
263-
# joinpath is not used because windows needs to complete with double-backslash
264-
push!(matches, id ? file * (@static Sys.iswindows() ? "\\\\" : "/") : file)
265+
p = joinpath(dir, file)
266+
is_dir = try isdir(p) catch; false end
267+
push!(matches, is_dir ? joinpath(file, "") : file)
265268
end
266269
end
267270

@@ -307,8 +310,14 @@ function complete_path(path::AbstractString, pos::Int; use_envpath=false, shell_
307310
end
308311
end
309312

310-
matchList = Completion[PathCompletion(shell_escape ? replace(s, r"\s" => s"\\\0") : s) for s in matches]
311-
startpos = pos - lastindex(prefix) + 1 - count(isequal(' '), prefix)
313+
function do_escape(s)
314+
return shell_escape ? replace(s, r"(\s|\\)" => s"\\\0") :
315+
string_escape ? escape_string(s, ('\"','$')) :
316+
s
317+
end
318+
319+
matchList = Completion[PathCompletion(do_escape(s)) for s in matches]
320+
startpos = pos - lastindex(do_escape(prefix)) + 1
312321
# The pos - lastindex(prefix) + 1 is correct due to `lastindex(prefix)-lastindex(prefix)==0`,
313322
# hence we need to add one to get the first index. This is also correct when considering
314323
# pos, because pos is the `lastindex` a larger string which `endswith(path)==true`.
@@ -767,7 +776,7 @@ end
767776
function close_path_completion(str, startpos, r, paths, pos)
768777
length(paths) == 1 || return false # Only close if there's a single choice...
769778
_path = str[startpos:prevind(str, first(r))] * (paths[1]::PathCompletion).path
770-
path = expanduser(replace(_path, r"\\ " => " "))
779+
path = expanduser(unescape_string(replace(_path, "\\\$"=>"\$", "\\\""=>"\"")))
771780
# ...except if it's a directory...
772781
try
773782
isdir(path)
@@ -1039,23 +1048,44 @@ function completions(string::String, pos::Int, context_module::Module=Main, shif
10391048
dotpos = something(findprev(isequal('.'), string, first(varrange)-1), 0)
10401049
return complete_identifiers!(Completion[], ffunc, context_module, string,
10411050
string[startpos:pos], pos, dotpos, startpos)
1042-
# otherwise...
1043-
elseif inc_tag in [:cmd, :string]
1051+
elseif inc_tag === :cmd
10441052
m = match(r"[\t\n\r\"`><=*?|]| (?!\\)", reverse(partial))
10451053
startpos = nextind(partial, reverseind(partial, m.offset))
10461054
r = startpos:pos
10471055

1056+
# This expansion with "\\ "=>' ' replacement and shell_escape=true
1057+
# assumes the path isn't further quoted within the cmd backticks.
10481058
expanded = complete_expanduser(replace(string[r], r"\\ " => " "), r)
10491059
expanded[3] && return expanded # If user expansion available, return it
10501060

1051-
paths, r, success = complete_path(replace(string[r], r"\\ " => " "), pos)
1061+
paths, r, success = complete_path(replace(string[r], r"\\ " => " "), pos,
1062+
shell_escape=true)
1063+
1064+
return sort!(paths, by=p->p.path), r, success
1065+
elseif inc_tag === :string
1066+
# Find first non-escaped quote
1067+
m = match(r"\"(?!\\)", reverse(partial))
1068+
startpos = nextind(partial, reverseind(partial, m.offset))
1069+
r = startpos:pos
1070+
1071+
expanded = complete_expanduser(string[r], r)
1072+
expanded[3] && return expanded # If user expansion available, return it
10521073

1053-
if inc_tag === :string && close_path_completion(string, startpos, r, paths, pos)
1054-
paths[1] = PathCompletion((paths[1]::PathCompletion).path * "\"")
1074+
path_prefix = try
1075+
unescape_string(replace(string[r], "\\\$"=>"\$", "\\\""=>"\""))
1076+
catch
1077+
nothing
10551078
end
1079+
if !isnothing(path_prefix)
1080+
paths, r, success = complete_path(path_prefix, pos, string_escape=true)
10561081

1057-
#Latex symbols can be completed for strings
1058-
(success || inc_tag === :cmd) && return sort!(paths, by=p->p.path), r, success
1082+
if close_path_completion(string, startpos, r, paths, pos)
1083+
paths[1] = PathCompletion((paths[1]::PathCompletion).path * "\"")
1084+
end
1085+
1086+
# Fallthrough allowed so that Latex symbols can be completed in strings
1087+
success && return sort!(paths, by=p->p.path), r, success
1088+
end
10591089
end
10601090

10611091
ok, ret = bslash_completions(string, pos)

stdlib/REPL/test/replcompletions.jl

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,7 @@ let current_dir, forbidden
11771177
catch e
11781178
e isa Base.IOError && occursin("ELOOP", e.msg)
11791179
end
1180-
c, r = test_complete("\"$(joinpath(path, "selfsym"))")
1180+
c, r = test_complete("\""*escape_string(joinpath(path, "selfsym")))
11811181
@test c == ["selfsymlink"]
11821182
end
11831183
end
@@ -1207,26 +1207,62 @@ end
12071207
mktempdir() do path
12081208
space_folder = randstring() * " α"
12091209
dir = joinpath(path, space_folder)
1210-
dir_space = replace(space_folder, " " => "\\ ")
1211-
12121210
mkdir(dir)
12131211
cd(path) do
1214-
open(joinpath(space_folder, "space .file"),"w") do f
1215-
s = Sys.iswindows() ? "rm $dir_space\\\\space" : "cd $dir_space/space"
1216-
c, r = test_scomplete(s)
1217-
@test r == lastindex(s)-4:lastindex(s)
1218-
@test "space\\ .file" in c
1212+
touch(joinpath(space_folder, "space .file"))
1213+
1214+
dir_space = replace(space_folder, " " => "\\ ")
1215+
s = Sys.iswindows() ? "cd $dir_space\\\\space" : "cd $dir_space/space"
1216+
c, r = test_scomplete(s)
1217+
@test s[r] == "space"
1218+
@test "space\\ .file" in c
1219+
# Also use shell escape rules within cmd backticks
1220+
s = "`$s"
1221+
c, r = test_scomplete(s)
1222+
@test s[r] == "space"
1223+
@test "space\\ .file" in c
1224+
1225+
# escape string according to Julia escaping rules
1226+
julia_esc(str) = escape_string(str, ('\"','$'))
1227+
1228+
# For normal strings the string should be properly escaped according to
1229+
# the usual rules for Julia strings.
1230+
s = "cd(\"" * julia_esc(joinpath(path, space_folder, "space"))
1231+
c, r = test_complete(s)
1232+
@test s[r] == "space"
1233+
@test "space .file\"" in c
1234+
1235+
# '$' is the only character which can appear in a windows filename and
1236+
# which needs to be escaped in Julia strings (on unix we could do this
1237+
# test with all sorts of special chars)
1238+
touch(joinpath(space_folder, "needs_escape\$.file"))
1239+
escpath = julia_esc(joinpath(path, space_folder, "needs_escape\$"))
1240+
s = "cd(\"$escpath"
1241+
c, r = test_complete(s)
1242+
@test s[r] == "needs_escape\\\$"
1243+
@test "needs_escape\\\$.file\"" in c
12191244

1220-
s = Sys.iswindows() ? "cd(\"β $dir_space\\\\space" : "cd(\"β $dir_space/space"
1245+
if !Sys.iswindows()
1246+
touch(joinpath(space_folder, "needs_escape2\n\".file"))
1247+
escpath = julia_esc(joinpath(path, space_folder, "needs_escape2\n\""))
1248+
s = "cd(\"$escpath"
12211249
c, r = test_complete(s)
1222-
@test r == lastindex(s)-4:lastindex(s)
1223-
@test "space .file\"" in c
1250+
@test s[r] == "needs_escape2\\n\\\""
1251+
@test "needs_escape2\\n\\\".file\"" in c
1252+
1253+
touch(joinpath(space_folder, "needs_escape3\\.file"))
1254+
escpath = julia_esc(joinpath(path, space_folder, "needs_escape3\\"))
1255+
s = "cd(\"$escpath"
1256+
c, r = test_complete(s)
1257+
@test s[r] == "needs_escape3\\\\"
1258+
@test "needs_escape3\\\\.file\"" in c
12241259
end
1260+
12251261
# Test for issue #10324
1226-
s = "cd(\"$dir_space"
1262+
s = "cd(\"$space_folder"
12271263
c, r = test_complete(s)
1228-
@test r == 5:15
1229-
@test s[r] == dir_space
1264+
@test r == 5:14
1265+
@test s[r] == space_folder
12301266

12311267
#Test for #18479
12321268
for c in "'`@\$;&"
@@ -1240,8 +1276,9 @@ mktempdir() do path
12401276
@test c[1] == test_dir*(Sys.iswindows() ? "\\\\" : "/")
12411277
@test res
12421278
end
1243-
c, r, res = test_complete("\""*test_dir)
1244-
@test c[1] == test_dir*(Sys.iswindows() ? "\\\\" : "/")
1279+
escdir = julia_esc(test_dir)
1280+
c, r, res = test_complete("\""*escdir)
1281+
@test c[1] == escdir*(Sys.iswindows() ? "\\\\" : "/")
12451282
@test res
12461283
finally
12471284
rm(joinpath(path, test_dir), recursive=true)
@@ -1285,7 +1322,7 @@ if Sys.iswindows()
12851322
@test r == length(s)-1:length(s)
12861323
@test file in c
12871324

1288-
s = "cd(\"..\\"
1325+
s = "cd(\"..\\\\"
12891326
c,r = test_complete(s)
12901327
@test r == length(s)+1:length(s)
12911328
@test temp_name * "\\\\" in c

0 commit comments

Comments
 (0)