diff --git a/.github/workflows/website-build.yml b/.github/workflows/website-build.yml index ff3344aa1..6b8407145 100644 --- a/.github/workflows/website-build.yml +++ b/.github/workflows/website-build.yml @@ -17,6 +17,7 @@ jobs: cmake -S . -B ./build -DCMAKE_BUILD_TYPE:STRING=Release -DSOURCEMETA_CORE_LANG_IO:BOOL=OFF + -DSOURCEMETA_CORE_LANG_PROCESS:BOOL=OFF -DSOURCEMETA_CORE_TIME:BOOL=OFF -DSOURCEMETA_CORE_UUID:BOOL=OFF -DSOURCEMETA_CORE_REGEX:BOOL=OFF diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml index 981a2b864..6b4193ce8 100644 --- a/.github/workflows/website-deploy.yml +++ b/.github/workflows/website-deploy.yml @@ -27,6 +27,7 @@ jobs: cmake -S . -B ./build -DCMAKE_BUILD_TYPE:STRING=Release -DSOURCEMETA_CORE_LANG_IO:BOOL=OFF + -DSOURCEMETA_CORE_LANG_PROCESS:BOOL=OFF -DSOURCEMETA_CORE_TIME:BOOL=OFF -DSOURCEMETA_CORE_UUID:BOOL=OFF -DSOURCEMETA_CORE_REGEX:BOOL=OFF diff --git a/CMakeLists.txt b/CMakeLists.txt index d53a10069..dce404efa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,7 @@ list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake") # Options option(SOURCEMETA_CORE_LANG_IO "Build the Sourcemeta Core language I/O library" ON) +option(SOURCEMETA_CORE_LANG_PROCESS "Build the Sourcemeta Core language Process library" ON) option(SOURCEMETA_CORE_LANG_PARALLEL "Build the Sourcemeta Core language parallel library" ON) option(SOURCEMETA_CORE_TIME "Build the Sourcemeta Core time library" ON) option(SOURCEMETA_CORE_UUID "Build the Sourcemeta Core UUID library" ON) @@ -58,6 +59,10 @@ if(SOURCEMETA_CORE_LANG_IO) add_subdirectory(src/lang/io) endif() +if(SOURCEMETA_CORE_LANG_PROCESS) + add_subdirectory(src/lang/process) +endif() + if(SOURCEMETA_CORE_LANG_PARALLEL) find_package(Threads REQUIRED) add_subdirectory(src/lang/parallel) @@ -161,6 +166,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/io) endif() + if(SOURCEMETA_CORE_LANG_PROCESS) + add_subdirectory(test/process) + endif() + if(SOURCEMETA_CORE_LANG_PARALLEL) add_subdirectory(test/parallel) endif() diff --git a/config.cmake.in b/config.cmake.in index 30ebd2d1b..3e1ed289f 100644 --- a/config.cmake.in +++ b/config.cmake.in @@ -5,6 +5,7 @@ list(APPEND SOURCEMETA_CORE_COMPONENTS ${Core_FIND_COMPONENTS}) list(APPEND SOURCEMETA_CORE_COMPONENTS ${core_FIND_COMPONENTS}) if(NOT SOURCEMETA_CORE_COMPONENTS) list(APPEND SOURCEMETA_CORE_COMPONENTS io) + list(APPEND SOURCEMETA_CORE_COMPONENTS process) list(APPEND SOURCEMETA_CORE_COMPONENTS parallel) list(APPEND SOURCEMETA_CORE_COMPONENTS time) list(APPEND SOURCEMETA_CORE_COMPONENTS uuid) @@ -28,6 +29,8 @@ include(CMakeFindDependencyMacro) foreach(component ${SOURCEMETA_CORE_COMPONENTS}) if(component STREQUAL "io") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") + elseif(component STREQUAL "process") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_process.cmake") elseif(component STREQUAL "parallel") find_dependency(Threads) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_parallel.cmake") diff --git a/src/lang/process/CMakeLists.txt b/src/lang/process/CMakeLists.txt new file mode 100644 index 000000000..b44d36c88 --- /dev/null +++ b/src/lang/process/CMakeLists.txt @@ -0,0 +1,7 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME process + PRIVATE_HEADERS error.h + SOURCES spawn.cc) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME process) +endif() diff --git a/src/lang/process/include/sourcemeta/core/process.h b/src/lang/process/include/sourcemeta/core/process.h new file mode 100644 index 000000000..237cf9eb9 --- /dev/null +++ b/src/lang/process/include/sourcemeta/core/process.h @@ -0,0 +1,72 @@ +#ifndef SOURCEMETA_CORE_PROCESS_H_ +#define SOURCEMETA_CORE_PROCESS_H_ + +#ifndef SOURCEMETA_CORE_PROCESS_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +// NOLINTEND(misc-include-cleaner) + +#include // std::filesystem +#include // std::initializer_list +#include // std::span +#include // std::string_view + +/// @defgroup process Process +/// @brief Process related utilities +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +namespace sourcemeta::core { + +/// @ingroup process +/// +/// Spawn a program piping its output to the current stdio configuration. +/// The directory parameter specifies the working directory for the spawned +/// process. It must be an absolute path to an existing directory. +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto exit_code{sourcemeta::core::spawn("echo", {"foo"})}; +/// assert(exit_code == 0); +/// ``` +SOURCEMETA_CORE_PROCESS_EXPORT +auto spawn(const std::string &program, + std::initializer_list arguments, + const std::filesystem::path &directory = + std::filesystem::current_path()) -> int; + +/// @ingroup process +/// +/// Spawn a program piping its output to the current stdio configuration. +/// This overload accepts a span for dynamic argument lists. +/// The directory parameter specifies the working directory for the spawned +/// process. It must be an absolute path to an existing directory. +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// std::vector arguments{"foo", "bar"}; +/// const auto exit_code{sourcemeta::core::spawn("echo", arguments)}; +/// assert(exit_code == 0); +/// ``` +SOURCEMETA_CORE_PROCESS_EXPORT +auto spawn( + const std::string &program, std::span arguments, + const std::filesystem::path &directory = std::filesystem::current_path()) + -> int; + +} // namespace sourcemeta::core + +#endif diff --git a/src/lang/process/include/sourcemeta/core/process_error.h b/src/lang/process/include/sourcemeta/core/process_error.h new file mode 100644 index 000000000..c06ba2377 --- /dev/null +++ b/src/lang/process/include/sourcemeta/core/process_error.h @@ -0,0 +1,83 @@ +#ifndef SOURCEMETA_CORE_PROCESS_ERROR_H_ +#define SOURCEMETA_CORE_PROCESS_ERROR_H_ + +#ifndef SOURCEMETA_CORE_PROCESS_EXPORT +#include +#endif + +#include // std::exception +#include // std::initializer_list +#include // std::span +#include // std::string +#include // std::string_view +#include // std::move +#include // std::vector + +namespace sourcemeta::core { + +// Exporting symbols that depends on the standard C++ library is considered +// safe. +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-2-c4275?view=msvc-170&redirectedfrom=MSDN +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup process +/// An executable program could not be found +class SOURCEMETA_CORE_PROCESS_EXPORT ProcessProgramNotNotFoundError + : public std::exception { +public: + ProcessProgramNotNotFoundError(std::string program) + : program_{std::move(program)} {} + + [[nodiscard]] auto what() const noexcept -> const char * override { + return "Could not locate the requested program"; + } + + [[nodiscard]] auto program() const noexcept -> std::string_view { + return this->program_; + } + +private: + std::string program_; +}; + +/// @ingroup process +/// A spawned process terminated abnormally +class SOURCEMETA_CORE_PROCESS_EXPORT ProcessSpawnError : public std::exception { +public: + ProcessSpawnError(std::string program, + std::initializer_list arguments) + : program_{std::move(program)}, + arguments_{arguments.begin(), arguments.end()} {} + + ProcessSpawnError(std::string program, + std::span arguments) + : program_{std::move(program)}, + arguments_{arguments.begin(), arguments.end()} {} + + [[nodiscard]] auto what() const noexcept -> const char * override { + return "Process terminated abnormally"; + } + + [[nodiscard]] auto program() const noexcept -> std::string_view { + return this->program_; + } + + [[nodiscard]] auto arguments() const noexcept + -> const std::vector & { + return this->arguments_; + } + +private: + std::string program_; + std::vector arguments_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/src/lang/process/spawn.cc b/src/lang/process/spawn.cc new file mode 100644 index 000000000..5209f1af2 --- /dev/null +++ b/src/lang/process/spawn.cc @@ -0,0 +1,152 @@ +#include + +#include // assert +#include // std::filesystem +#include // std::initializer_list +#include // std::span +#include // std::vector + +#if defined(_WIN32) && !defined(__MSYS__) && !defined(__CYGWIN__) && \ + !defined(__MINGW32__) && !defined(__MINGW64__) +#define WIN32_LEAN_AND_MEAN +#include // std::ostringstream +#include // CreateProcess, PROCESS_INFORMATION, STARTUPINFO, WaitForSingleObject, GetExitCodeProcess +#else +#include // posix_spawnp, posix_spawnattr_t, posix_spawnattr_init, posix_spawnattr_destroy, posix_spawn_file_actions_t, posix_spawn_file_actions_init, posix_spawn_file_actions_destroy, pid_t +#include // waitpid, WIFEXITED, WEXITSTATUS + +#if defined(__MSYS__) || defined(__CYGWIN__) || defined(__MINGW32__) || \ + defined(__MINGW64__) +#include // chdir +#endif + +extern char **environ; +#endif + +namespace sourcemeta::core { + +auto spawn(const std::string &program, + std::span arguments, + const std::filesystem::path &directory) -> int { + assert(directory.is_absolute()); + assert(std::filesystem::exists(directory)); + assert(std::filesystem::is_directory(directory)); + +#if defined(_WIN32) && !defined(__MSYS__) && !defined(__CYGWIN__) && \ + !defined(__MINGW32__) && !defined(__MINGW64__) + std::ostringstream command_line; + command_line << program; + + for (const auto &argument : arguments) { + command_line << " "; + // Quote arguments that contain spaces + const std::string arg_str{argument}; + if (arg_str.find(' ') != std::string::npos) { + command_line << "\"" << arg_str << "\""; + } else { + command_line << arg_str; + } + } + + std::string cmd_line_str = command_line.str(); + std::vector cmd_line(cmd_line_str.begin(), cmd_line_str.end()); + cmd_line.push_back('\0'); + + STARTUPINFOA startup_info{}; + startup_info.cb = sizeof(startup_info); + PROCESS_INFORMATION process_info{}; + const std::string working_dir = directory.string(); + const BOOL success = + CreateProcessA(nullptr, // lpApplicationName + cmd_line.data(), // lpCommandLine (modifiable) + nullptr, // lpProcessAttributes + nullptr, // lpThreadAttributes + TRUE, // bInheritHandles + 0, // dwCreationFlags + nullptr, // lpEnvironment + working_dir.c_str(), // lpCurrentDirectory + &startup_info, // lpStartupInfo + &process_info // lpProcessInformation + ); + + if (!success) { + throw ProcessProgramNotNotFoundError{program}; + } + + WaitForSingleObject(process_info.hProcess, INFINITE); + + DWORD exit_code; + if (!GetExitCodeProcess(process_info.hProcess, &exit_code)) { + CloseHandle(process_info.hProcess); + CloseHandle(process_info.hThread); + throw ProcessSpawnError{program, arguments}; + } + + CloseHandle(process_info.hProcess); + CloseHandle(process_info.hThread); + + return static_cast(exit_code); +#else + std::vector argv; + argv.reserve(arguments.size() + 2); + argv.push_back(program.c_str()); + + for (const auto &argument : arguments) { + argv.push_back(argument.data()); + } + + argv.push_back(nullptr); + + posix_spawnattr_t attributes; + posix_spawnattr_init(&attributes); + + posix_spawn_file_actions_t file_actions; + posix_spawn_file_actions_init(&file_actions); + +#if defined(__MSYS__) || defined(__CYGWIN__) || defined(__MINGW32__) || \ + defined(__MINGW64__) + const std::filesystem::path original_directory{ + std::filesystem::current_path()}; + std::filesystem::current_path(directory); +#else + posix_spawn_file_actions_addchdir_np(&file_actions, directory.c_str()); +#endif + + pid_t process_id; + const int spawn_result{ + posix_spawnp(&process_id, program.c_str(), &file_actions, &attributes, + const_cast(argv.data()), environ)}; + + posix_spawn_file_actions_destroy(&file_actions); + posix_spawnattr_destroy(&attributes); + +#if defined(__MSYS__) || defined(__CYGWIN__) || defined(__MINGW32__) || \ + defined(__MINGW64__) + std::filesystem::current_path(original_directory); +#endif + + if (spawn_result != 0) { + throw ProcessProgramNotNotFoundError{program}; + } + + int status; + waitpid(process_id, &status, 0); + + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } + + throw ProcessSpawnError{program, arguments}; +#endif +} + +auto spawn(const std::string &program, + std::initializer_list arguments, + const std::filesystem::path &directory) -> int { + return spawn( + program, + std::span{arguments.begin(), arguments.size()}, + directory); +} + +} // namespace sourcemeta::core diff --git a/test/parallel/parallel_for_each_test.cc b/test/parallel/parallel_for_each_test.cc index 8560cc3f0..036e51192 100644 --- a/test/parallel/parallel_for_each_test.cc +++ b/test/parallel/parallel_for_each_test.cc @@ -1,6 +1,6 @@ #include -#include "sourcemeta/core/parallel_for_each.h" +#include #include #include diff --git a/test/process/CMakeLists.txt b/test/process/CMakeLists.txt new file mode 100644 index 000000000..b0475241f --- /dev/null +++ b/test/process/CMakeLists.txt @@ -0,0 +1,20 @@ +if(WIN32) + add_test(NAME core.process.spawn.e2e + COMMAND powershell -ExecutionPolicy Bypass -File + "${CMAKE_CURRENT_SOURCE_DIR}/process_spawn_test.ps1" + "$") + sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME process + SOURCES process_spawn_test_windows.cc) +else() + add_test(NAME core.process.spawn.e2e + COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/process_spawn_test.sh" + "$") + sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME process + SOURCES process_spawn_test_unix.cc) +endif() + +target_link_libraries(sourcemeta_core_process_unit PRIVATE sourcemeta::core::process) + +add_executable(sourcemeta_core_process_unit_spawn_main process_spawn_main.cc) +target_link_libraries(sourcemeta_core_process_unit_spawn_main + PRIVATE sourcemeta::core::process) diff --git a/test/process/process_spawn_main.cc b/test/process/process_spawn_main.cc new file mode 100644 index 000000000..edd97acb9 --- /dev/null +++ b/test/process/process_spawn_main.cc @@ -0,0 +1,18 @@ +#include + +#include // std::filesystem::path +#include // std::string +#include // std::string_view +#include // std::vector + +auto main(int argc, char *argv[]) -> int { + const std::filesystem::path directory{argv[1]}; + const std::string program{argv[2]}; + std::vector arguments; + + for (int index = 3; index < argc; ++index) { + arguments.emplace_back(argv[index]); + } + + return sourcemeta::core::spawn(program, arguments, directory); +} diff --git a/test/process/process_spawn_test.ps1 b/test/process/process_spawn_test.ps1 new file mode 100644 index 000000000..72e2fe05d --- /dev/null +++ b/test/process/process_spawn_test.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ProcessSpawnMain +) + +$ErrorActionPreference = "Stop" +$TempDir = $env:TEMP +$Output = & $ProcessSpawnMain $TempDir "cmd.exe" "/c" "cd" 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -ne 0) { + Write-Error "FAIL: Expected exit code 0, got $ExitCode" + exit 1 +} + +$Expected = (Resolve-Path $TempDir).Path +$Output = $Output.Trim() + +if ($Output -ne $Expected) { + Write-Error "FAIL: Output mismatch" + Write-Error "Expected: $Expected" + Write-Error "Got: $Output" + exit 1 +} + +exit 0 diff --git a/test/process/process_spawn_test.sh b/test/process/process_spawn_test.sh new file mode 100755 index 000000000..3eb2945dc --- /dev/null +++ b/test/process/process_spawn_test.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +PROCESS_SPAWN_MAIN="$1" + +OUTPUT=$("$PROCESS_SPAWN_MAIN" /tmp /bin/sh -c 'echo "$(pwd)"' 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -ne 0 ] +then + echo "FAIL: Expected exit code 0, got $EXIT_CODE" >&2 + exit 1 +fi + +EXPECTED=$(realpath /tmp) + +if [ "$OUTPUT" != "$EXPECTED" ] +then + echo "FAIL: Output mismatch" >&2 + echo "Expected: $EXPECTED" >&2 + echo "Got: $OUTPUT" >&2 + exit 1 +fi diff --git a/test/process/process_spawn_test_unix.cc b/test/process/process_spawn_test_unix.cc new file mode 100644 index 000000000..4d2baec18 --- /dev/null +++ b/test/process/process_spawn_test_unix.cc @@ -0,0 +1,71 @@ +#include + +#include + +#include // std::filesystem::path + +TEST(Process_spawn, usr_bin_true_returns_zero) { + const int exit_code{sourcemeta::core::spawn("/usr/bin/true", {})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, usr_bin_false_returns_one) { + const int exit_code{sourcemeta::core::spawn("/usr/bin/false", {})}; + EXPECT_EQ(exit_code, 1); +} + +TEST(Process_spawn, true_without_path_returns_zero) { + const int exit_code{sourcemeta::core::spawn("true", {})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, sh_exit_with_specific_code) { + const int exit_code{sourcemeta::core::spawn("/bin/sh", {"-c", "exit 42"})}; + EXPECT_EQ(exit_code, 42); +} + +TEST(Process_spawn, sh_exit_with_different_code) { + const int exit_code{sourcemeta::core::spawn("/bin/sh", {"-c", "exit 7"})}; + EXPECT_EQ(exit_code, 7); +} + +TEST(Process_spawn, test_command_failing_condition) { + const int exit_code{sourcemeta::core::spawn("/bin/test", {"1", "-eq", "2"})}; + EXPECT_EQ(exit_code, 1); +} + +TEST(Process_spawn, test_command_passing_condition) { + const int exit_code{sourcemeta::core::spawn("/bin/test", {"1", "-eq", "1"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, nonexistent_program_throws_exception) { + const auto program{"/bin/this_program_definitely_does_not_exist"}; + EXPECT_THROW( + { + try { + sourcemeta::core::spawn(program, {}); + } catch ( + const sourcemeta::core::ProcessProgramNotNotFoundError &error) { + EXPECT_EQ(error.program(), program); + throw; + } + }, + sourcemeta::core::ProcessProgramNotNotFoundError); +} + +TEST(Process_spawn, echo_with_arguments) { + const int exit_code{sourcemeta::core::spawn("/bin/echo", {"hello", "world"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, pwd_with_custom_directory) { + const int exit_code{ + sourcemeta::core::spawn("pwd", {}, std::filesystem::path{"/tmp"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, pwd_with_current_directory) { + const int exit_code{sourcemeta::core::spawn("pwd", {})}; + EXPECT_EQ(exit_code, 0); +} diff --git a/test/process/process_spawn_test_windows.cc b/test/process/process_spawn_test_windows.cc new file mode 100644 index 000000000..aad6a2b38 --- /dev/null +++ b/test/process/process_spawn_test_windows.cc @@ -0,0 +1,72 @@ +#include + +#include + +#include // std::filesystem::path + +TEST(Process_spawn, cmd_exit_zero_returns_zero) { + const int exit_code{sourcemeta::core::spawn("cmd.exe", {"/c", "exit 0"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, cmd_exit_one_returns_one) { + const int exit_code{sourcemeta::core::spawn("cmd.exe", {"/c", "exit 1"})}; + EXPECT_EQ(exit_code, 1); +} + +TEST(Process_spawn, cmd_exit_with_specific_code) { + const int exit_code{sourcemeta::core::spawn("cmd.exe", {"/c", "exit 42"})}; + EXPECT_EQ(exit_code, 42); +} + +TEST(Process_spawn, cmd_exit_with_different_code) { + const int exit_code{sourcemeta::core::spawn("cmd.exe", {"/c", "exit 7"})}; + EXPECT_EQ(exit_code, 7); +} + +TEST(Process_spawn, where_command_success) { + // where.exe should successfully find cmd.exe + const int exit_code{sourcemeta::core::spawn("where.exe", {"cmd.exe"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, where_command_not_found) { + // where.exe should fail to find a nonexistent program + const int exit_code{sourcemeta::core::spawn( + "where.exe", {"this_program_definitely_does_not_exist"})}; + EXPECT_NE(exit_code, 0); +} + +TEST(Process_spawn, nonexistent_program_throws_exception) { + const auto program{"this_program_definitely_does_not_exist.exe"}; + EXPECT_THROW( + { + try { + sourcemeta::core::spawn(program, {}); + } catch ( + const sourcemeta::core::ProcessProgramNotNotFoundError &error) { + EXPECT_EQ(error.program(), program); + throw; + } + }, + sourcemeta::core::ProcessProgramNotNotFoundError); +} + +TEST(Process_spawn, cmd_echo_with_arguments) { + const int exit_code{ + sourcemeta::core::spawn("cmd.exe", {"/c", "echo hello world"})}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, cmd_cd_with_custom_directory) { + // Get the Windows temp directory + const auto temp_dir = std::filesystem::temp_directory_path(); + const int exit_code{ + sourcemeta::core::spawn("cmd.exe", {"/c", "cd"}, temp_dir)}; + EXPECT_EQ(exit_code, 0); +} + +TEST(Process_spawn, cmd_cd_with_current_directory) { + const int exit_code{sourcemeta::core::spawn("cmd.exe", {"/c", "cd"})}; + EXPECT_EQ(exit_code, 0); +}