Skip to content

feat(REPL): Make shell command handling extensible via dispatch; add Nushell #58525

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
163 changes: 120 additions & 43 deletions base/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -31,58 +31,135 @@ answer_color() = text_colors[repl_color("JULIA_ANSWER_COLOR", default_color_answ
stackframe_lineinfo_color() = repl_color("JULIA_STACKFRAME_LINEINFO_COLOR", :bold)
stackframe_function_color() = repl_color("JULIA_STACKFRAME_FUNCTION_COLOR", :bold)

function repl_cmd(cmd, out)
"""
ShellSpecification{is_windows, shell}

A type used for dispatch to select the appropriate shell command preparation logic.
It is parameterized by `is_windows::Bool` indicating the operating system,
and `shell::Symbol` representing the basename of the shell executable.
"""
struct ShellSpecification{is_windows,shell} end

"""
prepare_shell_command(spec::ShellSpecification, cmd::Cmd) -> Cmd
prepare_shell_command(spec::ShellSpecification, raw_string::String) -> Cmd

Returns a `Cmd` object configured for execution according to `spec`,
using the provided `cmd` (parsed command) or `raw_string` (original input).
Specialized methods for `ShellSpecification` define shell- and OS-specific behavior.

Define `Base.needs_cmd(::ShellSpecification)` to `false` for shells that do not require a `Cmd` as input.
They will then be passed the raw string instead.
"""
function prepare_shell_command(::ShellSpecification{true,shell}, cmd) where {shell}
return cmd
end
function prepare_shell_command(::ShellSpecification{false,shell}, cmd) where {shell}
shell_escape_cmd = "$(shell_escape_posixly(cmd)) && true"
return `$shell -c $shell_escape_cmd`
end
function prepare_shell_command(::ShellSpecification{false,:fish}, cmd)
shell_escape_cmd = "begin; $(shell_escape_posixly(cmd)); and true; end"
return `fish -c $shell_escape_cmd`
end
function prepare_shell_command(::ShellSpecification{false,:nu}, raw_string)
return `nu -c $raw_string`
end

"""
needs_cmd(::ShellSpecification) -> Bool

This trait is used to determine if the shell specification requires `Cmd` as input.
Setting this to `false` for a shell can help avoid specific parsing errors.
"""
needs_cmd(::ShellSpecification) = true
needs_cmd(::ShellSpecification{false,:nu}) = false

"""
is_cd_cmd(::ShellSpecification, cmd::Cmd) -> Bool
is_cd_cmd(::ShellSpecification, cmd::String) -> Bool

Determines if a command is a `cd` command. Overload this for
shells that have a different syntax for `cd`.
"""
is_cd_cmd(::ShellSpecification, cmd::Cmd) = cmd.exec[1] == "cd"
is_cd_cmd(::ShellSpecification, cmd::String) = false
is_cd_cmd(::ShellSpecification{false,:nu}, raw_string::String) = startswith(strip(raw_string), "cd")

function pre_repl_cmd(raw_string, parsed, out)
shell = shell_split(get(ENV, "JULIA_SHELL", get(ENV, "SHELL", "/bin/sh")))
shell_name = Base.basename(shell[1])

# Immediately expand all arguments, so that typing e.g. ~/bin/foo works.
shell_spec = ShellSpecification{@static(Sys.iswindows() ? true : false),Symbol(shell_name)}()
if needs_cmd(shell_spec)
cmd = Base.cmd_gen(parsed)
return repl_cmd(shell_spec, cmd, parsed, out)
else
return repl_cmd(shell_spec, raw_string, parsed, out)
end
end
function repl_cmd(shell_spec, cmd::Cmd, parsed, out)
cmd.exec .= expanduser.(cmd.exec)

if isempty(cmd.exec)
throw(ArgumentError("no cmd to execute"))
elseif cmd.exec[1] == "cd"
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
dir = ENV["OLDPWD"]
end
if is_cd_cmd(shell_spec, cmd)
return repl_cd_cmd(shell_spec, cmd, parsed, out)
end
return repl_cmd_execute(shell_spec, cmd, out)
end
function repl_cmd(shell_spec, raw_string::String, parsed, out)
if is_cd_cmd(shell_spec, raw_string)
return repl_cd_cmd(shell_spec, raw_string, parsed, out)
end
return repl_cmd_execute(shell_spec, raw_string, out)
end
function repl_cmd_execute(shell_spec, cmd_or_string, out)
prepared_cmd = prepare_shell_command(shell_spec, cmd_or_string)
try
run(ignorestatus(prepared_cmd))
catch
# Windows doesn't shell out right now (complex issue), so Julia tries to run the program itself
# Julia throws an exception if it can't find the program, but the stack trace isn't useful
lasterr = current_exceptions()
lasterr = ExceptionStack([(exception = e[1], backtrace = []) for e in lasterr])
invokelatest(display_error, lasterr)
end
nothing
end


"""
repl_cd_cmd(shell_spec::ShellSpecification, cmd, parsed, out)

Parses a `cd` command and executes it. Overload this for
shells that have a different syntax for `cd`.
"""
function repl_cd_cmd(::ShellSpecification, cmd, _, out)
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
else
dir = homedir()
dir = ENV["OLDPWD"]
end
try
ENV["OLDPWD"] = pwd()
catch ex
ex isa IOError || rethrow()
# if current dir has been deleted, then pwd() will throw an IOError: pwd(): no such file or directory (ENOENT)
delete!(ENV, "OLDPWD")
end
cd(dir)
println(out, pwd())
else
@static if !Sys.iswindows()
if shell_name == "fish"
shell_escape_cmd = "begin; $(shell_escape_posixly(cmd)); and true; end"
else
shell_escape_cmd = "($(shell_escape_posixly(cmd))) && true"
end
cmd = `$shell -c $shell_escape_cmd`
end
try
run(ignorestatus(cmd))
catch
# Windows doesn't shell out right now (complex issue), so Julia tries to run the program itself
# Julia throws an exception if it can't find the program, but the stack trace isn't useful
lasterr = current_exceptions()
lasterr = ExceptionStack([(exception = e[1], backtrace = [] ) for e in lasterr])
invokelatest(display_error, lasterr)
end
dir = homedir()
end
nothing
try
ENV["OLDPWD"] = pwd()
catch ex
ex isa IOError || rethrow()
# if current dir has been deleted, then pwd() will throw an IOError: pwd(): no such file or directory (ENOENT)
delete!(ENV, "OLDPWD")
end
cd(dir)
println(out, pwd())
end
function repl_cd_cmd(spec::ShellSpecification{false,:nu}, _, parsed, out)
repl_cd_cmd(spec, Base.cmd_gen(parsed), parsed, out)
end

# deprecated function--preserved for DocTests.jl
Expand Down
5 changes: 3 additions & 2 deletions stdlib/REPL/src/REPL.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1345,8 +1345,9 @@ function setup_interface(
# and pass into Base.repl_cmd for processing (handles `ls` and `cd`
# special)
on_done = respond(repl, julia_prompt) do line
Expr(:call, :(Base.repl_cmd),
:(Base.cmd_gen($(Base.shell_parse(line::String)[1]))),
Expr(:call, :(Base.pre_repl_cmd),
line::String,
Base.shell_parse(line)[1],
outstream(repl))
end,
sticky = true)
Expand Down
6 changes: 3 additions & 3 deletions test/file.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1925,15 +1925,15 @@ end
cd(dir) do
withenv("OLDPWD" => nothing) do
io = IOBuffer()
Base.repl_cmd(@cmd("cd"), io)
Base.repl_cmd(@cmd("cd -"), io)
Base.pre_repl_cmd("cd", eval(Base.shell_parse("cd")[1]), io)
Base.pre_repl_cmd("cd -", eval(Base.shell_parse("cd -")[1]), io)
@test realpath(pwd()) == realpath(dir)
if !Sys.iswindows()
# Delete the working directory and check we can cd out of it
# Cannot delete the working directory on Windows
rm(dir)
@test_throws Base._UVError("pwd()", Base.UV_ENOENT) pwd()
Base.repl_cmd(@cmd("cd \\~"), io)
Base.pre_repl_cmd("cd \\~", eval(Base.shell_parse("cd \\~")[1]), io)
end
end
end
Expand Down