Skip to content

Commit 0dfcedd

Browse files
committed
REPL shell mode for Windows
Introduce REPL shell mode on Windows. Co-authored-by: Mustafa M. <[email protected]> Co-authored-by: Jameson Nash <[email protected]"
1 parent 2b5faef commit 0dfcedd

File tree

6 files changed

+120
-22
lines changed

6 files changed

+120
-22
lines changed

base/Base.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ include("iobuffer.jl")
174174
include("intfuncs.jl")
175175
include("strings/strings.jl")
176176
include("parse.jl")
177-
include("shell.jl")
178177
include("regex.jl")
178+
include("shell.jl")
179179
include("show.jl")
180180
include("arrayshow.jl")
181181
include("methodshow.jl")

base/client.jl

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,26 @@ stackframe_lineinfo_color() = repl_color("JULIA_STACKFRAME_LINEINFO_COLOR", :bol
3131
stackframe_function_color() = repl_color("JULIA_STACKFRAME_FUNCTION_COLOR", :bold)
3232

3333
function repl_cmd(cmd, out)
34-
shell = shell_split(get(ENV, "JULIA_SHELL", get(ENV, "SHELL", "/bin/sh")))
34+
shell_env = get(ENV, "JULIA_SHELL", nothing)
35+
if shell_env === nothing || isempty(shell_env)
36+
shell_env = Sys.iswindows() ? "cmd" : get(ENV, "SHELL", "/bin/sh")
37+
end
38+
shell = shell_split(shell_env)
3539
shell_name = Base.basename(shell[1])
40+
Sys.iswindows() && (shell_name = lowercase(splitext(shell_name)[1])) # canonicalize for comparisons below
3641

3742
# Immediately expand all arguments, so that typing e.g. ~/bin/foo works.
3843
cmd.exec .= expanduser.(cmd.exec)
44+
isempty(cmd.exec) && throw(ArgumentError("no cmd to execute"))
3945

40-
if isempty(cmd.exec)
41-
throw(ArgumentError("no cmd to execute"))
42-
elseif cmd.exec[1] == "cd"
46+
if cmd.exec[1] == "cd"
4347
new_oldpwd = pwd()
4448
if length(cmd.exec) > 2
4549
throw(ArgumentError("cd method only takes one argument"))
4650
elseif length(cmd.exec) == 2
4751
dir = cmd.exec[2]
4852
if dir == "-"
49-
if !haskey(ENV, "OLDPWD")
50-
error("cd: OLDPWD not set")
51-
end
53+
!haskey(ENV, "OLDPWD") && error("cd: OLDPWD not set")
5254
cd(ENV["OLDPWD"])
5355
else
5456
@static if !Sys.iswindows()
@@ -66,19 +68,51 @@ function repl_cmd(cmd, out)
6668
ENV["OLDPWD"] = new_oldpwd
6769
println(out, pwd())
6870
else
69-
@static if !Sys.iswindows()
71+
local command
72+
if Sys.iswindows()
73+
if shell_name == "cmd"
74+
_CMD_execute(cmd)
75+
elseif shell_name in ("powershell", "pwsh")
76+
_powershell_cmd(cmd)
77+
elseif shell_name == "busybox"
78+
command = `$shell sh -c $(shell_escape_posixly(cmd))`
79+
else
80+
command = `$shell $cmd`
81+
end
82+
else
7083
if shell_name == "fish"
7184
shell_escape_cmd = "begin; $(shell_escape_posixly(cmd)); and true; end"
85+
elseif shell_name == "pwsh"
86+
_powershell_cmd(cmd)
7287
else
7388
shell_escape_cmd = "($(shell_escape_posixly(cmd))) && true"
7489
end
75-
cmd = `$shell -c $shell_escape_cmd`
90+
command = `$shell -c $shell_escape_cmd`
7691
end
77-
run(ignorestatus(cmd))
92+
run(ignorestatus(command))
7893
end
7994
nothing
8095
end
8196

97+
# process cmd's passed to CMD
98+
_CMD_execute(cmd) = Cmd(`$shell /c $(shell_escape_CMDly(shell_escape_winsomely(cmd)))`, windows_verbatim=true)
99+
100+
function _powershell_execute(cmd)
101+
# process cmd's passed to powershell
102+
CommandType = nothing
103+
try
104+
CommandType = readchomp(`$shell -Command "Get-Command -- $(shell_escape_PWSH_cmdlet_ly(cmd.exec[1])) | Select-Object -ExpandProperty CommandType"`)
105+
catch
106+
end
107+
# TODO: while CommandType == "Alias"; CommandType = ...; end
108+
if CommandType == "Application"
109+
command = Cmd(`$shell -Command "& $(shell_escape_PWSHly(shell_escape_winsomely(cmd)))"`)
110+
else # handle Function and Cmdlet # TODO: what is the proper handling for the other types (ExternalScript, Script, Workflow, Configuration, and Filter)
111+
command = Cmd(`$shell -Command "& $(shell_escape_PWSH_cmdlet_ly(cmd))"`)
112+
end
113+
return command
114+
end
115+
82116
# deprecated function--preserved for DocTests.jl
83117
function ip_matches_func(ip, func::Symbol)
84118
for fr in StackTraces.lookup(ip)

base/cmd.jl

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,10 @@ end
9898
hash(x::AndCmds, h::UInt) = hash(x.a, hash(x.b, h))
9999
==(x::AndCmds, y::AndCmds) = x.a == y.a && x.b == y.b
100100

101-
shell_escape(cmd::Cmd; special::AbstractString="") =
102-
shell_escape(cmd.exec..., special=special)
103-
shell_escape_posixly(cmd::Cmd) =
104-
shell_escape_posixly(cmd.exec...)
105-
shell_escape_winsomely(cmd::Cmd) =
106-
shell_escape_winsomely(cmd.exec...)
101+
shell_escape(cmd::Cmd; special::AbstractString="") = shell_escape(cmd.exec..., special=special)
102+
shell_escape_posixly(cmd::Cmd) = shell_escape_posixly(cmd.exec...)
103+
shell_escape_winsomely(cmd::Cmd) = shell_escape_winsomely(cmd.exec...)
104+
shell_escape_PWSH_cmdlet_ly(cmd::Cmd) = shell_escape_PWSH_cmdlet_ly(cmd.exec...)
107105

108106
function show(io::IO, cmd::Cmd)
109107
print_env = cmd.env !== nothing

base/shell.jl

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ end
292292

293293

294294
"""
295-
shell_escaped_winsomely(args::Union{Cmd,AbstractString...})::String
295+
shell_escaped_winsomely(args::Union{Cmd,AbstractString...}) -> String
296296
297297
Convert the collection of strings `args` into single string suitable for passing as the argument
298298
string for a Windows command line. Windows passes the entire command line as a single string to
@@ -312,3 +312,69 @@ julia> println(shell_escaped_winsomely("A B\\", "C"))
312312
"""
313313
shell_escape_winsomely(args::AbstractString...) =
314314
sprint(print_shell_escaped_winsomely, args..., sizehint=(sum(length, args)) + 3*length(args))
315+
316+
function print_shell_escaped_CMDly(io::IO, arg::AbstractString)
317+
any(c -> c in ('\r', '\n'), arg) && throw(ArgumentError("Encountered unsupported character by CMD."))
318+
# include " so to avoid toggling behavior of ^
319+
arg = replace(arg, r"[%!^\"<>&|]" => s"^\0")
320+
print(io, arg)
321+
end
322+
323+
"""
324+
shell_escape_CMDly(arg::AbstractString) -> String
325+
326+
The unexported `shell_escape_CMDly` function takes a string and escapes any special characters
327+
in such a way that it is safe to pass it as an argument to some `CMD.exe`. This may be useful
328+
in concert with the `windows_verbatim` flag to [`Cmd`](@ref) when constructing process
329+
pipelines.
330+
331+
See also [`shell_escape_PWSHly`](@ref).
332+
333+
# Example
334+
```jldoctest
335+
julia> println(shell_escape_CMDly("\"A B\\\" & C"))
336+
^"A B\\^" ^& C
337+
338+
!important
339+
Due to a peculiar behavior of the CMD, each command after a literal `|` character
340+
(indicating a command pipeline) must have `shell_escape_CMDly` applied twice. For example:
341+
```
342+
to_print = "All for 1 & 1 for all!"
343+
run(Cmd(Cmd(["cmd /c \"break | echo \$(shell_escape_CMDly(shell_escape_CMDly(to_print)))"]), windows_verbatim=true))
344+
```
345+
"""
346+
shell_escape_CMDly(arg::AbstractString) = sprint(print_shell_escaped_CMDly, arg)
347+
348+
function print_shell_escaped_PWSHly(io::IO, arg::AbstractString)
349+
# escape several characters that usually have special meaning
350+
arg = replace(arg, r"[`\"\$#;|><&(){}=]" => s"`\0")
351+
# escape special control chars
352+
arg = replace(replace(replace(arg, '\r' => "`r"), '\t' => "`t"), '\t' => "`t")
353+
print(io, arg)
354+
end
355+
356+
"""
357+
shell_escape_PWSHly(arg::AbstractString) -> String
358+
359+
Escapes special characters so they can be appropriately used with PowerShell.
360+
361+
See also [`shell_escape_CMDly`](@ref).
362+
"""
363+
shell_escape_PWSHly(arg::AbstractString) = sprint(print_shell_escaped_PWSHly, arg)
364+
365+
function print_shell_escaped_PWSH_cmdlet_ly(io::IO, args::AbstractString...)
366+
# often the shortest way to escape a powershell string is to double any single quotes and then wrap the whole thing in single quotes
367+
# (alternatively, we could prefix all the non-word characters with a back-tick and replace newlines with `r and `n)
368+
# but skip the escaping for common cases we always know are safe (e.g. so that named parameters are typically still interpreted correctly)
369+
isword(c::AbstractChar) = '0' <= c <= '9' || 'a' <= c <= 'z' || 'A' <= c <= 'Z' || c == '_' || c == '\\' || c == ':' || c == '/' || c == '-'
370+
join(io, (all(isword, arg) ? arg : string("'", replace(arg, "'" => "''"), "'") for arg in args), " ")
371+
end
372+
373+
"""
374+
shell_escape_PWSH_cmdlet_ly(args::AbstractString...) -> String
375+
376+
Escapes special characters so they can be appropriately used with a PowerShell cmdlet (such as `echo`).
377+
378+
See also [`shell_escape_PWSHly`](@ref) and [`shell_escape_winsomely`](@ref).
379+
"""
380+
shell_escape_PWSH_cmdlet_ly(args::AbstractString...) = sprint(print_shell_escaped_PWSH_cmdlet_ly, args...)

doc/src/manual/environment-variables.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,9 @@ The absolute path of the shell with which Julia should execute external commands
168168
(via `Base.repl_cmd()`). Defaults to the environment variable `$SHELL`, and
169169
falls back to `/bin/sh` if `$SHELL` is unset.
170170

171-
!!! note
172-
173-
On Windows, this environment variable is ignored, and external commands are
174-
executed directly.
171+
On Windows, `$JULIA_SHELL` can be set to `cmd`, `powershell`, `busybox` or `""`.
172+
If set to `""` external commands are executed directly. Defaults to `cmd` if
173+
`$JULIA_SHELL` is not set.
175174

176175
### `JULIA_EDITOR`
177176

stdlib/REPL/docs/src/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ julia> ; # upon typing ;, the prompt changes (in place) to: shell>
103103
shell> echo hello
104104
hello
105105
```
106+
See `JULIA_SHELL` in the Environment Variables section of the Julia manual.
106107

107108
### Search modes
108109

0 commit comments

Comments
 (0)