Skip to content

My Modern C++ Setup on macOS and Apple Silicon

I’m starting to learn modern C++ seriously (yes, seriously), so I wanted a clean and repeatable development setup on macOS with Apple Silicon.

My goals are simple:

This post is a cheatsheet and assumes that Homebrew is already installed.

Install Xcode Command Line Tools

First, install Apple’s basic developer tools:

xcode-select --install

Check that they are available:

xcode-select -p
clang --version

macOS ships Apple Clang, but for learning modern C++ I prefer installing a newer LLVM toolchain through Homebrew.

Install the core C++ toolchain

brew install \
  llvm \
  cmake \
  ninja \
  ccache \
  git \
  pkg-config

This gives us:

llvm is installed as a separate toolchain and its binaries may not be available in PATH automatically. Check the LLVM prefix first:

brew --prefix llvm

On Apple Silicon, it is usually /opt/homebrew/opt/llvm. Then check the LLVM tools directly:

$(brew --prefix llvm)/bin/clang++ --version
$(brew --prefix llvm)/bin/clangd --version
$(brew --prefix llvm)/bin/clang-format --version
$(brew --prefix llvm)/bin/clang-tidy --version

Now add Homebrew LLVM to your interactive shell:

echo 'export PATH="$(brew --prefix llvm)/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
rehash

For a small learning project the structure can as simple as this:

cpp-lab/
  CMakeLists.txt
  src/
    main.cpp
  tests/
    test_main.cpp
  build/
  .clang-format
  .clang-tidy
  .gitignore

For now, I create such a structure manually, but it can be easily generated with a custom script or CMake template later.

Minimal modern CMake setup

I’ve just started tinking with CMake, so I keep the setup minimal. The CMakeLists.txt looks like this:

cmake_minimum_required(VERSION 3.25)

project(cpp_lab LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_compile_options(
    -Wall
    -Wextra
    -Wpedantic
    -Wconversion
    -Wshadow
)

add_executable(cpp_lab src/main.cpp)

The important part here is:

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

It generates compile_commands.json, which helps clangd understand the project. No Cargo facilities like workspaces or dependencies for now, just a single executable target.

Build with LLVM and Ninja

Configure:

cmake -S . -B build -G Ninja \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
  -DCMAKE_BUILD_TYPE=Debug

Build:

cmake --build build

Run:

./build/cpp_lab

For a release build:

cmake -S . -B build-release -G Ninja \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
  -DCMAKE_BUILD_TYPE=Release

cmake --build build-release

Enable sanitizers early

When learning C++, sanitizers can help catch common mistakes and bad habits early on.

I’m experimenting with AddressSanitizer for memory bugs and UndefinedBehaviorSanitizer for undefined behavior.

cmake -S . -B build-asan -G Ninja \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"

cmake --build build-asan
./build-asan/cpp_lab

ThreadSanitizer is also useful for concurrency code:

cmake -S . -B build-tsan -G Ninja \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=thread -fno-omit-frame-pointer"

cmake --build build-tsan
./build-tsan/cpp_lab

Rule of thumb: do not mix AddressSanitizer and ThreadSanitizer in the same build, use separate build directories. This sanitizers are not compatible with each other and will cause false positives.

Add clang-format

clang-format helps keep the code style consistent and readable. The tools comes with llvm, so it is already installed.

The .clang-format file defines the code style. I prefer to use 4 spaces for indentation and a 100 character line limit.

BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: Empty
DerivePointerAlignment: false
PointerAlignment: Left

Formatting code can be done with the following command:

find src tests \( -name '*.cpp' -o -name '*.hpp' \) | xargs clang-format -i

This way we can format all source files in one go. The -i flag means “in-place”, so the files will be modified directly.

Add clang-tidy

clang-tidy is a powerful static analysis tool that can catch bugs, suggest improvements, and enforce coding standards. It also comes with llvm.

The .clang-tidy file configures the checks to run. I enable a broad set of checks from different categories, but you can customize it to your needs:

Checks: >
  clang-analyzer-*,
  bugprone-*,
  performance-*,
  modernize-*,
  readability-*,
  cppcoreguidelines-*

WarningsAsErrors: ''
HeaderFilterRegex: '.*'
FormatStyle: file

Run:

clang-tidy src/main.cpp -p build

Do not blindly apply every suggestion. clang-tidy is a reviewer, not a enforcer. Use your judgement to decide which suggestions to apply.

VS Code setup

Recommended extensions:

llvm-vs-code-extensions.vscode-clangd
ms-vscode.cmake-tools
vadimcn.vscode-lldb

If you use clangd, disable Microsoft IntelliSense to avoid duplicate diagnostics.

This is what to place in .vscode/settings.json:

{
  "clangd.path": "/opt/homebrew/opt/llvm/bin/clangd",
  "clangd.arguments": [
    "--background-index",
    "--clang-tidy",
    "--completion-style=detailed",
    "--header-insertion=iwyu"
  ],
  "C_Cpp.intelliSenseEngine": "disabled",
  "cmake.generator": "Ninja",
  "cmake.configureArgs": [
    "-DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang",
    "-DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++",
    "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"
  ]
}

If clangd does not understand your project, check that this file exists:

ls build/compile_commands.json

Helix setup

As I mentioned in my previous posts, I’m also trying out Helix as a lightweight editor in parallel to VS Code. I’ve already configured the editor for Python and Rust, so now it’s time to add C++ support.

Check health first:

hx --health cpp

Next, add the following configuration to ~/.config/helix/languages.toml:

[[language]]
name = "cpp"
language-servers = ["clangd"]
formatter = { command = "clang-format" }
auto-format = true

[language-server.clangd]
command = "/opt/homebrew/opt/llvm/bin/clangd"
args = [
  "--background-index",
  "--clang-tidy",
  "--completion-style=detailed",
  "--header-insertion=iwyu"
]

Useful Helix commands:

:config-reload
:format
:sh cmake --build build

mise setup

I use mise as a project environment manager.

Its main job is to install and activate the right tool versions for a project. It can also load project-specific environment variables and run project tasks. In small learning projects, this is a convenient way to keep common commands close to the code without introducing a Makefile too early.

For this C++ setup, mise is optional. You can run all CMake commands manually. But if you already use mise, a small mise.toml can make the workflow nicer.

Here is an example mise.toml:

[tasks.configure]
description = "Configure the Debug build with CMake and Ninja"
run = """
LLVM_PREFIX="$(brew --prefix llvm)"

cmake -S . -B build -G Ninja \
  -DCMAKE_C_COMPILER="$LLVM_PREFIX/bin/clang" \
  -DCMAKE_CXX_COMPILER="$LLVM_PREFIX/bin/clang++" \
  -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
  -DCMAKE_BUILD_TYPE=Debug
"""

[tasks.build]
description = "Build the project"
run = "cmake --build build"

[tasks.run]
description = "Run the executable"
run = "./build/cpp_lab"

[tasks.format]
description = "Format C++ source files"
run = """
find src tests -type f \\( -name '*.cpp' -o -name '*.hpp' -o -name '*.h' \\) 2>/dev/null \
  | xargs clang-format -i
"""

[tasks.tidy]
description = "Run clang-tidy on the main source file"
run = "clang-tidy src/main.cpp -p build"

Usage:

mise run configure
mise run build
mise run run
mise run format
mise run tidy

This gives a simple project workflow without inventing a custom shell script too early.

Minimal main.cpp

src/main.cpp:

#include <iostream>
#include <string_view>

void greet(std::string_view name)
{
    std::cout << "Hello, " << name << "!\n";
}

int main()
{
    greet("modern C++");
}

Build and run:

mise run configure
mise run build
mise run run

Expected output:

Hello, modern C++!

.gitignore

build/
build-*/
.cache/
.DS_Store
compile_commands.json

Optionally create a symlink for clangd:

ln -sf build/compile_commands.json compile_commands.json

Daily workflow

# Configure once
mise run configure

# Build
mise run build

# Run
mise run run

# Format
mise run format

# Static analysis
mise run tidy

# Clean build
rm -rf build
mise run configure
mise run build

Quick troubleshooting

clang++ still points to Apple Clang

which clang++

If it does not point to Homebrew LLVM, update your PATH:

export PATH="/opt/homebrew/opt/llvm/bin:$PATH"

Then reload your shell:

source ~/.zprofile

clangd cannot find headers

ls build/compile_commands.json

If it does not exist, reconfigure:

cmake -S . -B build -G Ninja \
  -DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
  -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
  -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

Helix does not see clangd

which clangd
hx --health cpp

Make sure languages.toml points to:

/opt/homebrew/opt/llvm/bin/clangd

VS Code shows duplicate diagnostics

If you use clangd, disable Microsoft IntelliSense:

{
  "C_Cpp.intelliSenseEngine": "disabled"
}

Final setup checklist

xcode-select -p
brew --version
clang++ --version
clangd --version
cmake --version
ninja --version
hx --health cpp
mise --version

If all commands work, the environment is ready.

Final thought

The goal is not to build the most complicated C++ setup possible.

The goal is to have a small, modern, repeatable environment where I can learn the language properly:

That is enough to start writing modern C++ and avoid old C++ habits from day one. Maybe I will add more tools later or change the setup completely, but for now this is a good starting point.