diff --git a/NEWS.md b/NEWS.md index c043d8cf618db..9ecf38a5d993e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -655,6 +655,8 @@ Library improvements * `trunc`, `floor`, `ceil`, and `round` specify `digits`, `sigdigits` and `base` using keyword arguments. ([#26156], [#26670]) + * `Sys.which()` provides a cross-platform method to find executable files, similar to + the Unix `which` command. ([#26559]) Compiler/Runtime improvements ----------------------------- diff --git a/base/download.jl b/base/download.jl index 1433fcbf27a03..ba9d8dd16d2a2 100644 --- a/base/download.jl +++ b/base/download.jl @@ -4,7 +4,7 @@ downloadcmd = nothing if Sys.iswindows() - downloadcmd = :powershell + downloadcmd = "powershell" function download(url::AbstractString, filename::AbstractString) ps = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" tls12 = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12" @@ -27,23 +27,25 @@ else function download(url::AbstractString, filename::AbstractString) global downloadcmd if downloadcmd === nothing - for checkcmd in (:curl, :wget, :fetch) - if success(pipeline(`which $checkcmd`, devnull)) + for checkcmd in ("curl", "wget", "fetch") + try + # Sys.which() will throw() if it can't find `checkcmd` + Sys.which(checkcmd) downloadcmd = checkcmd break end end end - if downloadcmd == :wget + if downloadcmd == "wget" try run(`wget -O $filename $url`) catch rm(filename) # wget always creates a file rethrow() end - elseif downloadcmd == :curl + elseif downloadcmd == "curl" run(`curl -g -L -f -o $filename $url`) - elseif downloadcmd == :fetch + elseif downloadcmd == "fetch" run(`fetch -f $filename $url`) else error("no download agent available; install curl, wget, or fetch") diff --git a/base/sysinfo.jl b/base/sysinfo.jl index a98c8835857d3..6cc6b55198a77 100644 --- a/base/sysinfo.jl +++ b/base/sysinfo.jl @@ -23,7 +23,9 @@ export BINDIR, isbsd, islinux, isunix, - iswindows + iswindows, + isexecutable, + which import ..Base: show @@ -312,14 +314,96 @@ if iswindows() else windows_version() = v"0.0" end + """ Sys.windows_version() -Returns the version number for the Windows NT Kernel as a `VersionNumber`, +Return the version number for the Windows NT Kernel as a `VersionNumber`, i.e. `v"major.minor.build"`, or `v"0.0.0"` if this is not running on Windows. """ windows_version const WINDOWS_VISTA_VER = v"6.0" +""" + Sys.isexecutable(path::String) + +Return `true` if the given `path` has executable permissions. +""" +function isexecutable(path::String) + if iswindows() + return isfile(path) + else + # We use `access()` and `X_OK` to determine if a given path is + # executable by the current user. `X_OK` comes from `unistd.h`. + X_OK = 0x01 + ccall(:access, Cint, (Ptr{UInt8}, Cint), path, X_OK) == 0 + end +end +isexecutable(path::AbstractString) = isexecutable(String(path)) + +""" + Sys.which(program_name::String) + +Given a program name, search the current `PATH` to find the first binary with +the proper executable permissions that can be run, and return an absolute +path. Raise `ErrorException` if no such program is available. If a path with +a directory in it is passed in for `program_name`, tests that exact path +for executable permissions only (with `.exe` and `.com` extensions added on +Windows platforms); no searching of `PATH` is performed. +""" +function which(program_name::String) + # Build a list of program names that we're going to try + program_names = String[] + base_pname = basename(program_name) + if iswindows() + # If the file already has an extension, try that name first + if !isempty(splitext(base_pname)[2]) + push!(program_names, base_pname) + end + + # But also try appending .exe and .com` + for pe in (".exe", ".com") + push!(program_names, string(base_pname, pe)) + end + else + # On non-windows, we just always search for what we've been given + push!(program_names, base_pname) + end + + path_dirs = String[] + program_dirname = dirname(program_name) + # If we've been given a path that has a directory name in it, then we + # check to see if that path exists. Otherwise, we search the PATH. + if isempty(program_dirname) + # If we have been given just a program name (not a relative or absolute + # path) then we should search `PATH` for it here: + pathsep = iswindows() ? ';' : ':' + path_dirs = abspath.(split(get(ENV, "PATH", ""), pathsep)) + + # On windows we always check the current directory as well + if iswindows() + pushfirst!(path_dirs, pwd()) + end + else + push!(path_dirs, abspath(program_dirname)) + end + + # Here we combine our directories with our program names, searching for the + # first match among all combinations. + for path_dir in path_dirs + for pname in program_names + program_path = joinpath(path_dir, pname) + # If we find something that matches our name and we can execute + if isexecutable(program_path) + return realpath(program_path) + end + end + end + + # If we couldn't find anything, complain + error("$program_name not found") +end +which(program_name::AbstractString) = which(String(program_name)) + end # module Sys diff --git a/test/spawn.jl b/test/spawn.jl index 7c3541b97aea4..684d4f657543c 100644 --- a/test/spawn.jl +++ b/test/spawn.jl @@ -536,3 +536,85 @@ let output = readchomp(pipeline(cmd, stderr=catcmd)) @test occursin("Info: Hello", output) end + +# Sys.which() testing +psep = if Sys.iswindows() ";" else ":" end +withenv("PATH" => "$(Sys.BINDIR)$(psep)$(ENV["PATH"])") do + julia_exe = joinpath(Sys.BINDIR, "julia") + if Sys.iswindows() + julia_exe *= ".exe" + end + + @test Sys.which("julia") == realpath(julia_exe) + @test Sys.which(julia_exe) == realpath(julia_exe) +end + +mktempdir() do dir + withenv("PATH" => "$(dir)$(psep)$(ENV["PATH"])") do + # Test that files lacking executable permissions fail Sys.which + # but only on non-Windows systems, as Windows doesn't care... + foo_path = joinpath(dir, "foo") + touch(foo_path) + chmod(foo_path, 0o777) + if !Sys.iswindows() + @test Sys.which("foo") == realpath(foo_path) + @test Sys.which(foo_path) == realpath(foo_path) + + chmod(foo_path, 0o666) + @test_throws ErrorException Sys.which("foo") + @test_throws ErrorException Sys.which(foo_path) + end + + # Test that completely missing files also fail + @test_throws ErrorException Sys.which("this_is_not_a_command") + end +end + +mktempdir() do dir + withenv("PATH" => "$(joinpath(dir, "bin1"))$(psep)$(joinpath(dir, "bin2"))$(psep)$(ENV["PATH"])") do + # Test that we have proper priorities + mkpath(joinpath(dir, "bin1")) + mkpath(joinpath(dir, "bin2")) + foo1_path = joinpath(dir, "bin1", "foo") + foo2_path = joinpath(dir, "bin2", "foo") + + # On windows, we find things with ".exe" and ".com" + if Sys.iswindows() + foo1_path *= ".exe" + foo2_path *= ".com" + end + + touch(foo1_path) + touch(foo2_path) + chmod(foo1_path, 0o777) + chmod(foo2_path, 0o777) + @test Sys.which("foo") == realpath(foo1_path) + + # chmod() doesn't change which() on Windows, so don't bother to test that + if !Sys.iswindows() + chmod(foo1_path, 0o666) + @test Sys.which("foo") == realpath(foo2_path) + chmod(foo1_path, 0o777) + end + + if Sys.iswindows() + # On windows, check that pwd() takes precedence, except when we provide a path + cd(joinpath(dir, "bin2")) do + @test Sys.which("foo") == realpath(foo2_path) + @test Sys.which(foo1_path) == realpath(foo1_path) + end + end + + # Check that "bin1/bar" will actually run "bin1/bar" + bar_path = joinpath(dir, "bin1", "bar") + if Sys.iswindows() + bar_path *= ".exe" + end + + touch(bar_path) + chmod(bar_path, 0o777) + cd(dir) do + @test Sys.which(joinpath("bin1", "bar")) == realpath(bar_path) + end + end +end