Cross-platform without complexity: command line

Spread the love

At this point we have a Windows build and remote Linux build working from Windows in the Visual Studio IDE. This is all good and it’s hard to beat VS in terms of productivity while actively developing the code. However, there are still plenty of scenarios where command line is superior (e.g. when setting up build automation).

Unfortunately, it is not quite as easy as running cmake.exe on the command line to get the same experience that VS has provided so far. There are actually three things that are happening when building and testing the project in VS:

  1. Producing native build system files via a CMake generator
  2. Executing the native build step
  3. Discovering and executing the tests

The first step is where CMake comes in but it must be properly initialized. This requires understanding which generator and make program to use as well as some of the required paths. By observing how VS launches CMake (Sysinternals Process Monitor FTW!), I was able to construct a command line that has identical behavior. It looks something like this:

cmake.exe -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER:FILEPATH=cl.exe -DCMAKE_INSTALL_PREFIX:PATH=”PROJ\PATH\out\install\x64-Debug” -DCMAKE_MAKE_PROGRAM=ninja.exe -DCMAKE_TOOLCHAIN_FILE=”VCPKG/PATH/scripts/buildsystems/vcpkg.cmake” “PROJ\PATH”

There are some paths to fill in based on the particular system and project, but this gives you a general idea. Also note that this command must be executed in the build output path, e.g. out\build\x64-Debug.

Given that VS uses the Ninja generator to produce build system files, we would need to invoke ninja.exe. In this case, it is just a simple matter of launching the EXE — any other arguments are optional and would only be needed if you wanted to change output logging behavior, for example.

Testing is also just as easy. Since CMake and Ninja have already done all the hard work, we would launch ctest.exe to execute tests.

Given the tedium of trying to stitch together all the required paths and setting up the right folders, these steps are just begging to be automated. So here is a batch file build.cmd which does just that:

@echo off
setlocal

pushd %~dp0
set ROOT_PATH=%CD%

set EXIT_CODE=1
if NOT DEFINED VSCMD_ARG_HOST_ARCH set ERR_MSG=%0 must be run from a VS command prompt.& goto :Quit

set VCPKG_FILE=%LOCALAPPDATA%\vcpkg\vcpkg.path.txt
if NOT EXIST "%VCPKG_FILE%" set ERR_MSG=Could not find vcpkg.& goto :Quit
set /p VCPKG_PATH=<%VCPKG_FILE%

set CLEAN=0
set NO_TEST=0
set VERBOSE=0
set BUILD_TYPE=Debug
set ERR_MSG=Usage: %0 [--clean] [--no-test] [--verbose] [build_type]

:NextArg
set NEXT_ARG=%~1
shift
if NOT DEFINED NEXT_ARG goto :EndArg
if /i "%NEXT_ARG%" == "--clean" set CLEAN=1& goto :NextArg
if /i "%NEXT_ARG%" == "--no-test" set NO_TEST=1& goto :NextArg
if /i "%NEXT_ARG%" == "--verbose" set VERBOSE=1& goto :NextArg
if "%NEXT_ARG:~0,1%" == "-" goto :Quit
set BUILD_TYPE=%NEXT_ARG%
if /i "%BUILD_TYPE%" == "Debug" goto :NextArg
if /i "%BUILD_TYPE%" == "Release" goto :NextArg
goto :Quit
:EndArg

set BUILD_CONFIG=%VSCMD_ARG_HOST_ARCH%-%BUILD_TYPE%
echo == Build config '%BUILD_CONFIG%'

if /i "%BUILD_TYPE%" == "Release" set BUILD_TYPE=RelWithDebInfo

set OUTPUT_PATH=%ROOT_PATH%\out
set BUILD_PATH=%OUTPUT_PATH%\build\%BUILD_CONFIG%

if "%CLEAN%" == "0" goto :Make
echo == Remove %BUILD_PATH%
if EXIST "%BUILD_PATH%" rd /s /q "%BUILD_PATH%"

:Make
if NOT EXIST "%BUILD_PATH%" md "%BUILD_PATH%"
cd "%BUILD_PATH%"

set CMAKE_ARGS=^
  -G Ninja ^
  -DCMAKE_BUILD_TYPE=%BUILD_TYPE% ^
  -DCMAKE_CXX_COMPILER:FILEPATH=cl.exe ^
  -DCMAKE_INSTALL_PREFIX:PATH="%OUTPUT_PATH%\install\%BUILD_CONFIG%" ^
  -DCMAKE_MAKE_PROGRAM=ninja.exe ^
  -DCMAKE_TOOLCHAIN_FILE="%VCPKG_PATH%/scripts/buildsystems/vcpkg.cmake" ^
  "%ROOT_PATH%"

if "%VERBOSE%" == "1" set CMAKE_ARGS=%CMAKE_ARGS% --log-level=VERBOSE

echo == cmake.exe %CMAKE_ARGS%
cmake.exe %CMAKE_ARGS%
set EXIT_CODE=%ERRORLEVEL%
set ERR_MSG=cmake.exe failed.
if NOT "%EXIT_CODE%" == "0" goto :Quit

set NINJA_ARGS=

if "%VERBOSE%" == "1" set NINJA_ARGS=%NINJA_ARGS% -v

echo == ninja.exe %NINJA_ARGS%
ninja.exe %NINJA_ARGS%
set EXIT_CODE=%ERRORLEVEL%
set ERR_MSG=ninja.exe failed.
if NOT "%EXIT_CODE%" == "0" goto :Quit

if "%NO_TEST%" == "1" goto :Quit

set CTEST_ARGS=

if "%VERBOSE%" == "1" set CTEST_ARGS=%CTEST_ARGS% -V

echo == ctest.exe %CTEST_ARGS%
ctest.exe %CTEST_ARGS%
set ERR_MSG=ctest.exe failed.
set EXIT_CODE=%ERRORLEVEL%

:Quit
popd
if NOT "%EXIT_CODE%" == "0" echo %ERR_MSG%
exit /b %EXIT_CODE%

Note the additional functionality which can be enabled via command line options: --clean to delete the previous output folder before starting, --no-test to skip the testing step, and --verbose to pass verbose flags to each of the tools. The default build target is Debug, or you can say build Release for, you guessed it, the release build. Since this assumes the same development environment as Visual Studio, it should be run from inside a VS developer command prompt.

Running it for the first time or on any changes will generate all the binaries and run tests. Running it without any changes should quickly blaze past the generation/compilation steps and just run tests:

== Build config 'x64-release'
== cmake.exe   -G Ninja   -DCMAKE_BUILD_TYPE=RelWithDebInfo   -DCMAKE_CXX_COMPILER:FILEPATH=cl.exe   -DCMAKE_INSTALL_PREFIX:PATH="X:\root\CMakeSampleVS\out\install\x64-release"   -DCMAKE_MAKE_PROGRAM=ninja.exe   -DCMAKE_TOOLCHAIN_FILE="X:/root/vcpkg/scripts/buildsystems/vcpkg.cmake"   "X:\root\CMakeSampleVS"
Re-run cmake no build system arguments
-- Configuring done
-- Generating done
-- Build files have been written to: X:/root/CMakeSampleVS/out/build/x64-release
== ninja.exe
ninja: no work to do.
== ctest.exe
Test project X:/root/CMakeSampleVS/out/build/x64-release
    Start 1: SampleTest.GetName
1/3 Test #1: SampleTest.GetName ...............   Passed    0.11 sec
    Start 2: sample-app-UsageError
2/3 Test #2: sample-app-UsageError ............   Passed    0.01 sec
    Start 3: sample-app-HelloWorld
3/3 Test #3: sample-app-HelloWorld ............   Passed    0.01 sec

100% tests passed, 0 tests failed out of 3

Total Test time (real) =   0.14 sec

If Windows were our only concern, we would be done here. But I promised cross-platform, so we need to figure out the equivalent steps in a Linux environment. Luckily for us, in this case, the steps are very similar. We just need a Linux-native way to perform them, which means it’s time for Bash! Here is build.sh, a relatively faithful port of the above script using the all-around superior Bash constructs (conditionals, looping, functions, etc.):

#!/bin/bash

quit() {
    echo $1
    popd > /dev/null 
    exit $2;
}

pushd `dirname $0` > /dev/null
ROOT_PATH="${PWD}"

CLEAN=0
NO_TEST=0
VERBOSE=0
BUILD_TYPE="Debug"

while [[ "$1" != "" ]] ; do
  case $1 in
    --clean)   CLEAN=1 ;;
    --no-test) NO_TEST=1 ;;
    --verbose) VERBOSE=1 ;;
    debug)     BUILD_TYPE="Debug" ;;
    release)   BUILD_TYPE="Release" ;;
    *) quit "Usage: $0 [--clean] [--no-test] [--verbose] [build_type]" 1 ;;
  esac

  shift
done

BUILD_CONFIG="Linux-Clang-${BUILD_TYPE}"
echo == Build config $BUILD_CONFIG

[[ "${BUILD_TYPE}" = "Release" ]] && BUILD_TYPE="RelWithDebInfo"

OUTPUT_PATH="${ROOT_PATH}/out"
BUILD_PATH="${OUTPUT_PATH}/build/${BUILD_CONFIG}"

if [[ $CLEAN -eq 1 ]]; then
  echo "== Remove ${BUILD_PATH}"
  [[ -d "${BUILD_PATH}" ]] && rm -rf "${BUILD_PATH}"
fi

[[ ! -d "${BUILD_PATH}" ]] && mkdir "${BUILD_PATH}"
cd "${BUILD_PATH}"

CMAKE_ARGS="
  -G Ninja
  -DCMAKE_BUILD_TYPE=${BUILD_TYPE}
  -DCMAKE_CXX_COMPILER:FILEPATH=clang++
  -DCMAKE_INSTALL_PREFIX:PATH=${OUTPUT_PATH}/install/${BUILD_CONFIG}
  -S ${ROOT_PATH}
"

[[ $VERBOSE -eq 1 ]] && CMAKE_ARGS="${CMAKE_ARGS} --log-level=VERBOSE"

echo == cmake $CMAKE_ARGS
cmake $CMAKE_ARGS
EXIT_CODE=$?
[[ $EXIT_CODE -ne 0 ]] && quit 'cmake failed' $EXIT_CODE

NINJA_ARGS=""

[[ $VERBOSE -eq 1 ]] && NINJA_ARGS="${NINJA_ARGS} -v"

echo == ninja $NINJA_ARGS
ninja $NINJA_ARGS
EXIT_CODE=$?
[[ $EXIT_CODE -ne 0 ]] && quit 'ninja failed' $EXIT_CODE

[[ $NO_TEST -eq 1 ]] && quit 'Done' 0

CTEST_ARGS=""

[[ $VERBOSE -eq 1 ]] && CTEST_ARGS="${CTEST_ARGS} -V"

echo == ctest $CTEST_ARGS
ctest $CTEST_ARGS
EXIT_CODE=$?
[[ $EXIT_CODE -ne 0 ]] && quit 'ctest failed' $EXIT_CODE

quit 'Done' 0

You might notice that the cmake arguments are slightly different here. For Linux, we don’t really need vcpkg and we choose the CLang compiler since MSVC (cl.exe) wouldn’t make sense. I also found that it has trouble with quoted paths so I omitted them. Anyway, just as we had hoped, the Linux behavior is equivalent to Windows when launching the script from an already produced Release build:

== Build config Linux-Clang-Release
== cmake -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER:FILEPATH=clang++ -DCMAKE_INSTALL_PREFIX:PATH=/mnt/x/root/CMakeSampleVS/out/install/Linux-Clang-Release -S /mnt/x/root/CMakeSampleVS
-- Configuring done
-- Generating done
-- Build files have been written to: /mnt/x/root/CMakeSampleVS/out/build/Linux-Clang-Release
== ninja
ninja: no work to do.
== ctest
Test project /mnt/x/root/CMakeSampleVS/out/build/Linux-Clang-Release
    Start 1: SampleTest.GetName
1/3 Test #1: SampleTest.GetName ...............   Passed    0.01 sec
    Start 2: sample-app-UsageError
2/3 Test #2: sample-app-UsageError ............   Passed    0.01 sec
    Start 3: sample-app-HelloWorld
3/3 Test #3: sample-app-HelloWorld ............   Passed    0.01 sec

100% tests passed, 0 tests failed out of 3

Total Test time (real) =   0.04 sec
Done

Here is the GitHub repo with the changes: CMakeSampleVS

Amazingly, we have achieved our original goals. We have a project structure which works with the VS IDE on Windows, produces platform-specific binaries for Windows and Linux, supports a command line build for both Windows and Linux, and has full unit test support on both platforms. What kinds of modern C++ software could we create with these newfound capabilities?

Leave a Reply

Your email address will not be published. Required fields are marked *