diff --git a/base/Base.jl b/base/Base.jl index c39b08b13539b..7247d770b0206 100644 --- a/base/Base.jl +++ b/base/Base.jl @@ -174,8 +174,8 @@ include("iobuffer.jl") include("intfuncs.jl") include("strings/strings.jl") include("parse.jl") -include("shell.jl") include("regex.jl") +include("shell.jl") include("show.jl") include("arrayshow.jl") include("methodshow.jl") diff --git a/base/client.jl b/base/client.jl index 8f5a89fbb8666..b2aaeb7aedd22 100644 --- a/base/client.jl +++ b/base/client.jl @@ -31,24 +31,26 @@ stackframe_lineinfo_color() = repl_color("JULIA_STACKFRAME_LINEINFO_COLOR", :bol stackframe_function_color() = repl_color("JULIA_STACKFRAME_FUNCTION_COLOR", :bold) function repl_cmd(cmd, out) - shell = shell_split(get(ENV, "JULIA_SHELL", get(ENV, "SHELL", "/bin/sh"))) + shell_env = get(ENV, "JULIA_SHELL", nothing) + if shell_env === nothing || isempty(shell_env) + shell_env = Sys.iswindows() ? "cmd" : get(ENV, "SHELL", "/bin/sh") + end + shell = shell_split(shell_env) shell_name = Base.basename(shell[1]) + Sys.iswindows() && (shell_name = lowercase(splitext(shell_name)[1])) # canonicalize for comparisons below # Immediately expand all arguments, so that typing e.g. ~/bin/foo works. cmd.exec .= expanduser.(cmd.exec) + isempty(cmd.exec) && throw(ArgumentError("no cmd to execute")) - if isempty(cmd.exec) - throw(ArgumentError("no cmd to execute")) - elseif cmd.exec[1] == "cd" + if cmd.exec[1] == "cd" new_oldpwd = pwd() if length(cmd.exec) > 2 throw(ArgumentError("cd method only takes one argument")) elseif length(cmd.exec) == 2 dir = cmd.exec[2] if dir == "-" - if !haskey(ENV, "OLDPWD") - error("cd: OLDPWD not set") - end + !haskey(ENV, "OLDPWD") && error("cd: OLDPWD not set") cd(ENV["OLDPWD"]) else @static if !Sys.iswindows() @@ -66,19 +68,51 @@ function repl_cmd(cmd, out) ENV["OLDPWD"] = new_oldpwd println(out, pwd()) else - @static if !Sys.iswindows() + local command + if Sys.iswindows() + if shell_name == "cmd" + command = _CMD_execute(cmd) + elseif shell_name in ("powershell", "pwsh") + command = _powershell_execute(cmd) + elseif shell_name == "busybox" + command = `$shell sh -c $(shell_escape_posixly(cmd))` + else + command = `$shell $cmd` + end + else if shell_name == "fish" shell_escape_cmd = "begin; $(shell_escape_posixly(cmd)); and true; end" + elseif shell_name == "pwsh" + command = _powershell_execute(cmd) else shell_escape_cmd = "($(shell_escape_posixly(cmd))) && true" end - cmd = `$shell -c $shell_escape_cmd` + command = `$shell -c $shell_escape_cmd` end - run(ignorestatus(cmd)) + run(ignorestatus(command)) end nothing end +# process cmd's passed to CMD +_CMD_execute(cmd) = Cmd(`$shell /c $(shell_escape_CMDly(shell_escape_winsomely(cmd)))`, windows_verbatim=true) + +function _powershell_execute(cmd) + # process cmd's passed to powershell + CommandType = nothing + try + CommandType = readchomp(`$shell -Command "Get-Command -- $(shell_escape_PWSH_cmdlet_ly(cmd.exec[1])) | Select-Object -ExpandProperty CommandType"`) + catch + end + # TODO: while CommandType == "Alias"; CommandType = ...; end + if CommandType == "Application" + command = Cmd(`$shell -Command "& $(shell_escape_PWSHly(shell_escape_winsomely(cmd)))"`) + else # handle Function and Cmdlet # TODO: what is the proper handling for the other types (ExternalScript, Script, Workflow, Configuration, and Filter) + command = Cmd(`$shell -Command "& $(shell_escape_PWSH_cmdlet_ly(cmd))"`) + end + return command +end + # deprecated function--preserved for DocTests.jl function ip_matches_func(ip, func::Symbol) for fr in StackTraces.lookup(ip) diff --git a/base/cmd.jl b/base/cmd.jl index 4890af1c4c7cd..95bae703e512a 100644 --- a/base/cmd.jl +++ b/base/cmd.jl @@ -98,12 +98,10 @@ end hash(x::AndCmds, h::UInt) = hash(x.a, hash(x.b, h)) ==(x::AndCmds, y::AndCmds) = x.a == y.a && x.b == y.b -shell_escape(cmd::Cmd; special::AbstractString="") = - shell_escape(cmd.exec..., special=special) -shell_escape_posixly(cmd::Cmd) = - shell_escape_posixly(cmd.exec...) -shell_escape_winsomely(cmd::Cmd) = - shell_escape_winsomely(cmd.exec...) +shell_escape(cmd::Cmd; special::AbstractString="") = shell_escape(cmd.exec..., special=special) +shell_escape_posixly(cmd::Cmd) = shell_escape_posixly(cmd.exec...) +shell_escape_winsomely(cmd::Cmd) = shell_escape_winsomely(cmd.exec...) +shell_escape_PWSH_cmdlet_ly(cmd::Cmd) = shell_escape_PWSH_cmdlet_ly(cmd.exec...) function show(io::IO, cmd::Cmd) print_env = cmd.env !== nothing diff --git a/base/shell.jl b/base/shell.jl index 85243a3e40542..d6f9f5a77e82e 100644 --- a/base/shell.jl +++ b/base/shell.jl @@ -292,7 +292,7 @@ end """ - shell_escaped_winsomely(args::Union{Cmd,AbstractString...})::String + shell_escaped_winsomely(args::Union{Cmd,AbstractString...}) -> String Convert the collection of strings `args` into single string suitable for passing as the argument 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")) """ shell_escape_winsomely(args::AbstractString...) = sprint(print_shell_escaped_winsomely, args..., sizehint=(sum(length, args)) + 3*length(args)) + +function print_shell_escaped_CMDly(io::IO, arg::AbstractString) + any(c -> c in ('\r', '\n'), arg) && throw(ArgumentError("Encountered unsupported character by CMD.")) + # include " so to avoid toggling behavior of ^ + arg = replace(arg, r"[%!^\"<>&|]" => s"^\0") + print(io, arg) +end + +""" + shell_escape_CMDly(arg::AbstractString) -> String + +The unexported `shell_escape_CMDly` function takes a string and escapes any special characters +in such a way that it is safe to pass it as an argument to some `CMD.exe`. This may be useful +in concert with the `windows_verbatim` flag to [`Cmd`](@ref) when constructing process +pipelines. + +See also [`shell_escape_PWSHly`](@ref). + +# Example +```jldoctest +julia> println(shell_escape_CMDly("\"A B\\\" & C")) +^"A B\\^" ^& C + +!important + Due to a peculiar behavior of the CMD, each command after a literal `|` character + (indicating a command pipeline) must have `shell_escape_CMDly` applied twice. For example: + ``` + to_print = "All for 1 & 1 for all!" + run(Cmd(Cmd(["cmd /c \"break | echo \$(shell_escape_CMDly(shell_escape_CMDly(to_print)))"]), windows_verbatim=true)) + ``` +""" +shell_escape_CMDly(arg::AbstractString) = sprint(print_shell_escaped_CMDly, arg) + +function print_shell_escaped_PWSHly(io::IO, arg::AbstractString) + # escape several characters that usually have special meaning + arg = replace(arg, r"[`\"\$#;|><&(){}=]" => s"`\0") + # escape special control chars + arg = replace(replace(replace(arg, '\r' => "`r"), '\t' => "`t"), '\t' => "`t") + print(io, arg) +end + +""" + shell_escape_PWSHly(arg::AbstractString) -> String + +Escapes special characters so they can be appropriately used with PowerShell. + +See also [`shell_escape_CMDly`](@ref). +""" +shell_escape_PWSHly(arg::AbstractString) = sprint(print_shell_escaped_PWSHly, arg) + +function print_shell_escaped_PWSH_cmdlet_ly(io::IO, args::AbstractString...) + # often the shortest way to escape a powershell string is to double any single quotes and then wrap the whole thing in single quotes + # (alternatively, we could prefix all the non-word characters with a back-tick and replace newlines with `r and `n) + # but skip the escaping for common cases we always know are safe (e.g. so that named parameters are typically still interpreted correctly) + isword(c::AbstractChar) = '0' <= c <= '9' || 'a' <= c <= 'z' || 'A' <= c <= 'Z' || c == '_' || c == '\\' || c == ':' || c == '/' || c == '-' + join(io, (all(isword, arg) ? arg : string("'", replace(arg, "'" => "''"), "'") for arg in args), " ") +end + +""" + shell_escape_PWSH_cmdlet_ly(args::AbstractString...) -> String + +Escapes special characters so they can be appropriately used with a PowerShell cmdlet (such as `echo`). + +See also [`shell_escape_PWSHly`](@ref) and [`shell_escape_winsomely`](@ref). +""" +shell_escape_PWSH_cmdlet_ly(args::AbstractString...) = sprint(print_shell_escaped_PWSH_cmdlet_ly, args...) diff --git a/doc/src/manual/environment-variables.md b/doc/src/manual/environment-variables.md index 8976b5f40050c..e9510426d5936 100644 --- a/doc/src/manual/environment-variables.md +++ b/doc/src/manual/environment-variables.md @@ -168,10 +168,9 @@ The absolute path of the shell with which Julia should execute external commands (via `Base.repl_cmd()`). Defaults to the environment variable `$SHELL`, and falls back to `/bin/sh` if `$SHELL` is unset. -!!! note - - On Windows, this environment variable is ignored, and external commands are - executed directly. +On Windows, `$JULIA_SHELL` can be set to `cmd`, `powershell`, `busybox` or `""`. +If set to `""` external commands are executed directly. Defaults to `cmd` if +`$JULIA_SHELL` is not set. ### `JULIA_EDITOR` diff --git a/stdlib/REPL/docs/src/index.md b/stdlib/REPL/docs/src/index.md index 323d0f13fbbe7..4dd53ef83825b 100644 --- a/stdlib/REPL/docs/src/index.md +++ b/stdlib/REPL/docs/src/index.md @@ -103,6 +103,7 @@ julia> ; # upon typing ;, the prompt changes (in place) to: shell> shell> echo hello hello ``` +See `JULIA_SHELL` in the Environment Variables section of the Julia manual. ### Search modes